refactor: remove QMD semantic indexing — keep keyword search only
Remove the entire QMD search engine (semantic indexing, hybrid search, vector embeddings) and replace with a simple walkdir-based keyword search. QMD never worked reliably in production, added bundle bloat, and showed "Index failed" in the status bar. What changed: - Rust: deleted indexing.rs, rewrote search.rs as pure keyword search - Frontend: removed useIndexing hook, IndexingBadge, hybrid search - Menu: removed "Reindex Vault" command from palette and menu bar - Config: removed qmd from bundle resources and build script - Deleted tools/qmd/ (QMD CLI tool, MCP server, tests) - Updated docs (ARCHITECTURE, ABSTRACTIONS, GETTING-STARTED) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -490,42 +490,29 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
`useClosedTabHistory` hook (`src/hooks/useClosedTabHistory.ts`) provides a LIFO stack for closed tab entries, used by `useTabManagement` to support Cmd+Shift+T reopen. Each entry stores the note's path, tab index, and full `VaultEntry`. The stack is in-memory only (resets on restart), capped at 20 entries, and deduplicates by path.
|
||||
|
||||
## Search & Indexing
|
||||
## Search
|
||||
|
||||
### Search Modes
|
||||
### Search
|
||||
|
||||
Keyword-based search scans all vault `.md` files using `walkdir`:
|
||||
|
||||
```typescript
|
||||
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
path: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
elapsedMs: number
|
||||
}
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
|
||||
`SearchPanel` component provides the search UI:
|
||||
- Mode selector (keyword/semantic/hybrid)
|
||||
- Real-time results as user types
|
||||
- Real-time results as user types (300ms debounce)
|
||||
- Click result to open note in editor
|
||||
- Shows relevance score and snippet
|
||||
|
||||
### Indexing
|
||||
|
||||
Managed by `useIndexing` hook:
|
||||
- Checks index status on vault load
|
||||
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
|
||||
- Progress streamed via Tauri events
|
||||
- Incremental updates after git sync
|
||||
- Metadata persisted in `.laputa-index.json`
|
||||
No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
## Vault Management
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ flowchart LR
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - |
|
||||
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
|
||||
| Search | qmd (keyword + semantic + hybrid) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
| Package manager | pnpm | - |
|
||||
@@ -113,7 +113,7 @@ flowchart TD
|
||||
GIT["git/"]
|
||||
GH["github/"]
|
||||
THEME["theme/"]
|
||||
SEARCH["search.rs + indexing.rs"]
|
||||
SEARCH["search.rs"]
|
||||
CLI["claude_cli.rs"]
|
||||
end
|
||||
|
||||
@@ -121,7 +121,6 @@ flowchart TD
|
||||
ANTH["Anthropic API\n(Claude chat)"]
|
||||
CCLI["Claude CLI\n(agent subprocess)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
QMD["qmd\n(search engine)"]
|
||||
GHAPI["GitHub API\n(OAuth, repos, clone)"]
|
||||
end
|
||||
|
||||
@@ -359,53 +358,16 @@ flowchart LR
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
|
||||
## Search & Indexing
|
||||
## Search
|
||||
|
||||
### Search Engine
|
||||
Search is keyword-based, using `walkdir` to scan all `.md` files in the vault directory. No external binary or indexing step required.
|
||||
|
||||
Search uses the external `qmd` binary (semantic search engine) with three modes:
|
||||
- Matches query against file titles and content (case-insensitive)
|
||||
- Scores results: title matches ranked higher than content-only matches
|
||||
- Extracts contextual snippets around the first match
|
||||
- Skips trashed and hidden files
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| `keyword` | `qmd search` | Term matching (default) |
|
||||
| `semantic` | `qmd vsearch` | Vector similarity search |
|
||||
| `hybrid` | `qmd query` | Combined keyword + semantic |
|
||||
|
||||
### Indexing Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Vault opened]) --> B[check_index_status]
|
||||
B --> C{Index status?}
|
||||
C -->|Fresh| D[run_incremental_update\ngit diff since last commit]
|
||||
C -->|Stale / Missing| E
|
||||
|
||||
subgraph E[Full Indexing — start_indexing]
|
||||
E1["Phase 1: qmd update\n(scan all .md files)"]
|
||||
E2["Phase 2: qmd embed\n(generate vector embeddings)"]
|
||||
E1 --> E2
|
||||
end
|
||||
|
||||
E --> F[Save .laputa-index.json\nlast_indexed_commit + timestamp]
|
||||
D --> G([Search ready])
|
||||
F --> G
|
||||
|
||||
E2 -.->|failure is non-fatal| G
|
||||
G --> H{Search mode}
|
||||
H -->|keyword| I[qmd search]
|
||||
H -->|semantic| J[qmd vsearch]
|
||||
H -->|hybrid| K[qmd query]
|
||||
```
|
||||
|
||||
Embedding failure is non-fatal — keyword search still works.
|
||||
|
||||
### qmd Binary Resolution
|
||||
|
||||
1. Bundled macOS app resource: `<app>/Contents/Resources/qmd/qmd`
|
||||
2. Dev mode: `CARGO_MANIFEST_DIR/resources/qmd/qmd`
|
||||
3. System locations: `~/.bun/bin/qmd`, `/usr/local/bin/qmd`, `/opt/homebrew/bin/qmd`
|
||||
4. PATH lookup via `which qmd`
|
||||
5. Auto-install via `bun install -g qmd` if missing
|
||||
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
|
||||
|
||||
## Vault Cache System
|
||||
|
||||
@@ -533,7 +495,6 @@ sequenceDiagram
|
||||
VL->>T: invoke('get_modified_files')
|
||||
VL->>T: useMcpStatus — register if needed
|
||||
VL->>T: useThemeManager — load active theme
|
||||
VL->>T: useIndexing — incremental update if stale
|
||||
VL-->>A: entries ready
|
||||
end
|
||||
|
||||
@@ -619,8 +580,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
| `theme/` | Theme management (`mod.rs`, `create.rs`, `defaults.rs`, `seed.rs`) |
|
||||
| `search.rs` | qmd search integration (keyword/semantic/hybrid) |
|
||||
| `indexing.rs` | qmd indexing with progress streaming |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
@@ -689,14 +649,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `github_create_repo` | Create new repo |
|
||||
| `clone_repo` | Clone repo with token auth |
|
||||
|
||||
### Search & Indexing
|
||||
### Search
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search_vault` | Search via qmd (keyword/semantic/hybrid) |
|
||||
| `get_index_status` | Check qmd index state |
|
||||
| `start_indexing` | Full index with progress streaming |
|
||||
| `trigger_incremental_index` | Incremental index update |
|
||||
| `search_vault` | Keyword search across vault files |
|
||||
|
||||
### Theme
|
||||
|
||||
@@ -772,7 +729,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
|
||||
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useIndexing` | Index status, progress | Search indexing |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Node.js** 18+ and **pnpm**
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
- **qmd** (optional — for search indexing; auto-installed if missing)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -97,7 +96,7 @@ laputa-app/
|
||||
│ │ ├── useEditorSave.ts # Auto-save with debounce
|
||||
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
|
||||
│ │ ├── useThemeManager.ts # Vault theme lifecycle
|
||||
│ │ ├── useIndexing.ts # Search indexing management
|
||||
│ │ ├── useUnifiedSearch.ts # Keyword search
|
||||
│ │ ├── useNoteSearch.ts # Note search
|
||||
│ │ ├── useCommandRegistry.ts # Command palette registry
|
||||
│ │ ├── useAppCommands.ts # App-level commands
|
||||
@@ -158,8 +157,7 @@ laputa-app/
|
||||
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
|
||||
│ │ ├── theme/ # Theme module
|
||||
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
|
||||
│ │ ├── search.rs # qmd search integration
|
||||
│ │ ├── indexing.rs # qmd indexing + progress streaming
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── ai_chat.rs # Direct Anthropic API client
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + registration
|
||||
@@ -220,7 +218,7 @@ laputa-app/
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
|
||||
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
|
||||
### Editor
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bundle qmd into a self-contained directory for Tauri resource embedding.
|
||||
#
|
||||
# Output: src-tauri/resources/qmd/
|
||||
# qmd — compiled standalone binary
|
||||
# node_modules/sqlite-vec/ — JS shim for sqlite-vec
|
||||
# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64)
|
||||
# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64)
|
||||
# node_modules/node-llama-cpp/ — stub (keyword search only)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$SCRIPT_DIR/.."
|
||||
OUT="$ROOT/src-tauri/resources/qmd"
|
||||
|
||||
# ---------- locate tools ----------
|
||||
find_bun() {
|
||||
for c in \
|
||||
"$HOME/.bun/bin/bun" \
|
||||
"/opt/homebrew/bin/bun" \
|
||||
"/usr/local/bin/bun"; do
|
||||
[[ -x "$c" ]] && { echo "$c"; return 0; }
|
||||
done
|
||||
command -v bun 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; }
|
||||
echo "Using bun: $BUN"
|
||||
|
||||
# ---------- locate qmd source ----------
|
||||
# Prefer bundled source in tools/qmd/ (works in CI and dev),
|
||||
# then fall back to globally installed qmd on dev machines.
|
||||
QMD_SRC=""
|
||||
for c in \
|
||||
"$ROOT/tools/qmd" \
|
||||
"$HOME/.bun/install/global/node_modules/qmd" \
|
||||
"/opt/homebrew/lib/node_modules/qmd" \
|
||||
"/usr/local/lib/node_modules/qmd"; do
|
||||
[[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; }
|
||||
done
|
||||
|
||||
[[ -n "$QMD_SRC" ]] || { echo "ERROR: qmd source not found. tools/qmd/ is missing or incomplete." >&2; exit 1; }
|
||||
echo "Using qmd source: $QMD_SRC"
|
||||
|
||||
# Install qmd dependencies if needed (for CI where node_modules don't exist yet)
|
||||
if [[ ! -d "$QMD_SRC/node_modules" ]]; then
|
||||
echo "Installing qmd dependencies..."
|
||||
(cd "$QMD_SRC" && "$BUN" install --frozen-lockfile)
|
||||
fi
|
||||
|
||||
# ---------- compile ----------
|
||||
echo "Compiling qmd with bun build --compile..."
|
||||
mkdir -p "$OUT"
|
||||
|
||||
(cd "$QMD_SRC" && "$BUN" build --compile \
|
||||
"src/qmd.ts" \
|
||||
--outfile "$OUT/qmd" \
|
||||
--external node-llama-cpp \
|
||||
--external sqlite-vec \
|
||||
--external sqlite-vec-darwin-arm64 \
|
||||
--external sqlite-vec-darwin-x64)
|
||||
|
||||
chmod +x "$OUT/qmd"
|
||||
|
||||
# ---------- bundle sqlite-vec ----------
|
||||
echo "Bundling sqlite-vec native extensions..."
|
||||
|
||||
# Find sqlite-vec packages — prefer node_modules in QMD_SRC (after bun install),
|
||||
# fall back to bun global cache for dev machines.
|
||||
NM="$QMD_SRC/node_modules"
|
||||
|
||||
find_pkg() {
|
||||
local pkg="$1"
|
||||
# Check node_modules from bun install in QMD_SRC first
|
||||
if [[ -d "$NM/$pkg" ]]; then
|
||||
echo "$NM/$pkg"; return 0
|
||||
fi
|
||||
# Fall back to bun global cache
|
||||
local cache_dir
|
||||
cache_dir=$(find "$HOME/.bun/install/cache" -maxdepth 1 -name "${pkg}@*" -type d 2>/dev/null | head -1)
|
||||
[[ -n "$cache_dir" ]] && echo "$cache_dir" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# sqlite-vec JS shim
|
||||
SQLVEC_DIR=$(find_pkg "sqlite-vec") || { echo "ERROR: sqlite-vec not found" >&2; exit 1; }
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec"
|
||||
cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs"
|
||||
cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json"
|
||||
[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs"
|
||||
|
||||
# sqlite-vec-darwin-arm64
|
||||
ARM64_DIR=$(find_pkg "sqlite-vec-darwin-arm64") || true
|
||||
if [[ -n "$ARM64_DIR" ]]; then
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64"
|
||||
cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib"
|
||||
cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json"
|
||||
echo " ✓ arm64 dylib"
|
||||
fi
|
||||
|
||||
# sqlite-vec-darwin-x64
|
||||
X64_DIR=$(find_pkg "sqlite-vec-darwin-x64") || true
|
||||
if [[ -n "$X64_DIR" ]]; then
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64"
|
||||
cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib"
|
||||
cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json"
|
||||
echo " ✓ x64 dylib"
|
||||
fi
|
||||
|
||||
# ---------- stub node-llama-cpp ----------
|
||||
echo "Creating node-llama-cpp stub (keyword search only)..."
|
||||
mkdir -p "$OUT/node_modules/node-llama-cpp"
|
||||
|
||||
cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON'
|
||||
{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"}
|
||||
PJSON
|
||||
|
||||
cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB'
|
||||
// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works.
|
||||
const unavailable = (name) => (...args) => {
|
||||
throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`);
|
||||
};
|
||||
export const getLlama = unavailable("getLlama");
|
||||
export const resolveModelFile = unavailable("resolveModelFile");
|
||||
export class LlamaChatSession {
|
||||
constructor() { throw new Error("LlamaChatSession unavailable"); }
|
||||
}
|
||||
export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 };
|
||||
STUB
|
||||
|
||||
# ---------- code signing (macOS) ----------
|
||||
# In CI (APPLE_SIGNING_IDENTITY set): sign with Developer ID + hardened runtime (required for notarization)
|
||||
# In dev (no identity): ad-hoc sign to remove quarantine
|
||||
if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then
|
||||
SIGN_ID="${APPLE_SIGNING_IDENTITY:--}"
|
||||
if [[ "$SIGN_ID" != "-" ]]; then
|
||||
echo "Signing bundled binaries with Developer ID: $SIGN_ID"
|
||||
SIGN_OPTS=(--force --sign "$SIGN_ID" --options runtime --timestamp)
|
||||
else
|
||||
echo "Ad-hoc signing bundled binaries (dev mode)..."
|
||||
SIGN_OPTS=(--force --sign -)
|
||||
fi
|
||||
codesign "${SIGN_OPTS[@]}" "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)"
|
||||
while IFS= read -r -d '' dylib; do
|
||||
codesign "${SIGN_OPTS[@]}" "$dylib" 2>/dev/null && echo " ✓ $(basename "$dylib") signed" || echo " ⚠ $(basename "$dylib") signing failed (non-fatal)"
|
||||
done < <(find "$OUT/node_modules" -name "*.dylib" -print0)
|
||||
fi
|
||||
|
||||
# ---------- summary ----------
|
||||
echo ""
|
||||
echo "qmd bundled → $OUT/"
|
||||
du -sh "$OUT/qmd"
|
||||
du -sh "$OUT/node_modules"
|
||||
echo "Done."
|
||||
@@ -1,8 +1,8 @@
|
||||
fn main() {
|
||||
// Ensure resource directories exist for the Tauri build.
|
||||
// These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs).
|
||||
// These are gitignored and populated by scripts (bundle-mcp-server.mjs).
|
||||
// Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run.
|
||||
for dir in ["resources/qmd", "resources/mcp-server"] {
|
||||
for dir in ["resources/mcp-server"] {
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path).ok();
|
||||
|
||||
@@ -10,12 +10,11 @@ use crate::git::{
|
||||
PulseCommit,
|
||||
};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
use crate::settings::Settings;
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault_list::VaultList;
|
||||
use crate::{frontmatter, git, github, indexing, menu, search, vault, vault_list};
|
||||
use crate::{frontmatter, git, github, menu, search, vault, vault_list};
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
@@ -42,20 +41,6 @@ pub fn parse_build_label(version: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "unavailable".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some("qmd not available".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vault commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -423,7 +408,7 @@ pub async fn stream_claude_agent(
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── Search & indexing commands ──────────────────────────────────────────────
|
||||
// ── Search commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_vault(
|
||||
@@ -439,66 +424,6 @@ pub async fn search_vault(
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_indexing(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
log::info!("qmd binary not found — attempting auto-install via bun");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match indexing::try_auto_install_qmd() {
|
||||
Ok(()) if indexing::find_qmd_binary().is_some() => {
|
||||
log::info!("qmd auto-installed successfully, proceeding with indexing");
|
||||
}
|
||||
Ok(()) => {
|
||||
log::warn!("qmd auto-install reported success but binary still not found");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err("qmd not available after install".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("qmd auto-install failed: {e}");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err(format!("qmd not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexing::run_full_index(&vault_path, |progress| {
|
||||
let _ = app_handle.emit("indexing-progress", &progress);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Indexing task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Incremental index failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── MCP commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,914 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Resolved qmd binary location: path + optional working directory.
|
||||
/// The working dir is required for the bundled binary to find its node_modules.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QmdBinary {
|
||||
pub path: String,
|
||||
pub work_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl QmdBinary {
|
||||
/// Create a `Command` pre-configured with the correct working directory.
|
||||
pub fn command(&self) -> Command {
|
||||
let mut cmd = Command::new(&self.path);
|
||||
if let Some(ref dir) = self.work_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
static QMD_CACHE: Mutex<Option<QmdBinary>> = Mutex::new(None);
|
||||
|
||||
/// Locate the qmd binary, checking bundled resource first, then known locations.
|
||||
/// Caches the result for subsequent calls.
|
||||
pub fn find_qmd_binary() -> Option<QmdBinary> {
|
||||
if let Ok(guard) = QMD_CACHE.lock() {
|
||||
if let Some(ref cached) = *guard {
|
||||
return Some(cached.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = find_qmd_binary_uncached();
|
||||
|
||||
if let Some(ref bin) = result {
|
||||
if let Ok(mut guard) = QMD_CACHE.lock() {
|
||||
*guard = Some(bin.clone());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn find_qmd_binary_uncached() -> Option<QmdBinary> {
|
||||
// 1. Check bundled binary (Tauri resource)
|
||||
if let Some(bin) = find_bundled_qmd() {
|
||||
return Some(bin);
|
||||
}
|
||||
|
||||
// 2. Check known system locations
|
||||
let candidates = [
|
||||
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
|
||||
Some("/usr/local/bin/qmd".to_string()),
|
||||
Some("/opt/homebrew/bin/qmd".to_string()),
|
||||
];
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
if Path::new(&candidate).exists() {
|
||||
return Some(QmdBinary {
|
||||
path: candidate,
|
||||
work_dir: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: try PATH
|
||||
Command::new("which")
|
||||
.arg("qmd")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(QmdBinary {
|
||||
path: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
work_dir: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Look for the bundled qmd binary inside the app bundle or dev resources.
|
||||
fn find_bundled_qmd() -> Option<QmdBinary> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let exe_dir = exe.parent()?;
|
||||
|
||||
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/Contents/Resources/qmd/qmd
|
||||
let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd");
|
||||
if let Some(bin) = prepare_bundled_dir(&bundle_dir) {
|
||||
return Some(bin);
|
||||
}
|
||||
|
||||
// Dev mode: use compile-time CARGO_MANIFEST_DIR for reliable path resolution
|
||||
let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("qmd");
|
||||
if let Some(bin) = prepare_bundled_dir(&dev_dir) {
|
||||
return Some(bin);
|
||||
}
|
||||
|
||||
// Dev mode fallback: walk up from exe_dir to find the project root
|
||||
let mut dir = exe_dir.to_path_buf();
|
||||
for _ in 0..6 {
|
||||
let qmd_dir = dir.join("resources").join("qmd");
|
||||
if let Some(bin) = prepare_bundled_dir(&qmd_dir) {
|
||||
return Some(bin);
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a bundled qmd directory and prepare the binary for execution.
|
||||
/// Sets execute permissions and removes macOS quarantine attributes.
|
||||
fn prepare_bundled_dir(qmd_dir: &Path) -> Option<QmdBinary> {
|
||||
let qmd_path = qmd_dir.join("qmd");
|
||||
if !qmd_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
ensure_executable(&qmd_path);
|
||||
|
||||
// Remove macOS quarantine attributes that block execution of bundled binaries
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-rd", "com.apple.quarantine"])
|
||||
.arg(qmd_dir)
|
||||
.output();
|
||||
}
|
||||
|
||||
Some(QmdBinary {
|
||||
path: qmd_path.to_string_lossy().to_string(),
|
||||
work_dir: Some(qmd_dir.to_path_buf()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure a file has execute permission.
|
||||
#[cfg(unix)]
|
||||
fn ensure_executable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let mode = metadata.permissions().mode();
|
||||
if mode & 0o111 == 0 {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(mode | 0o755);
|
||||
let _ = std::fs::set_permissions(path, perms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ensure_executable(_path: &Path) {}
|
||||
|
||||
/// Try to install qmd globally using bun. Returns Ok if installation succeeded.
|
||||
pub fn try_auto_install_qmd() -> Result<(), String> {
|
||||
let bun = find_bun().ok_or("bun not found — cannot auto-install qmd")?;
|
||||
|
||||
log::info!("Auto-installing qmd via bun...");
|
||||
let output = Command::new(&bun)
|
||||
.args(["install", "-g", "qmd"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run bun install: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("bun install -g qmd failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Clear cache so the newly installed binary is discovered
|
||||
clear_qmd_cache();
|
||||
log::info!("qmd auto-install succeeded");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate bun binary for auto-installing qmd.
|
||||
fn find_bun() -> Option<PathBuf> {
|
||||
let candidates = [
|
||||
dirs::home_dir().map(|h| h.join(".bun/bin/bun")),
|
||||
Some(PathBuf::from("/opt/homebrew/bin/bun")),
|
||||
Some(PathBuf::from("/usr/local/bin/bun")),
|
||||
];
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try PATH
|
||||
Command::new("which")
|
||||
.arg("bun")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(PathBuf::from(
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the cached qmd binary (e.g. after path changes or installation).
|
||||
pub fn clear_qmd_cache() {
|
||||
if let Ok(mut guard) = QMD_CACHE.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct IndexStatus {
|
||||
pub available: bool,
|
||||
pub qmd_installed: bool,
|
||||
pub collection_exists: bool,
|
||||
pub indexed_count: usize,
|
||||
pub embedded_count: usize,
|
||||
pub pending_embed: usize,
|
||||
pub last_indexed_commit: Option<String>,
|
||||
pub last_indexed_at: Option<u64>,
|
||||
}
|
||||
|
||||
// --- Index metadata persistence ---
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
struct IndexMetadata {
|
||||
#[serde(default)]
|
||||
last_indexed_commit: Option<String>,
|
||||
#[serde(default)]
|
||||
last_indexed_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn index_metadata_path(vault_path: &str) -> PathBuf {
|
||||
Path::new(vault_path).join(".laputa-index.json")
|
||||
}
|
||||
|
||||
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
|
||||
let path = index_metadata_path(vault_path);
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
|
||||
let path = index_metadata_path(vault_path);
|
||||
let json =
|
||||
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
|
||||
}
|
||||
|
||||
/// Get the current HEAD commit hash for a vault.
|
||||
fn get_head_commit(vault_path: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the current HEAD as the last indexed commit.
|
||||
fn stamp_index_commit(vault_path: &str) {
|
||||
if let Some(commit) = get_head_commit(vault_path) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some(commit),
|
||||
last_indexed_at: Some(now),
|
||||
};
|
||||
let _ = save_index_metadata(vault_path, &meta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the vault has a qmd index and its status.
|
||||
pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
let meta = load_index_metadata(vault_path);
|
||||
|
||||
let qmd = match find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
return IndexStatus {
|
||||
available: false,
|
||||
qmd_installed: false,
|
||||
collection_exists: false,
|
||||
indexed_count: 0,
|
||||
embedded_count: 0,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: meta.last_indexed_commit,
|
||||
last_indexed_at: meta.last_indexed_at,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
let output = qmd.command().args(["status"]).output();
|
||||
|
||||
let mut status = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
parse_status_for_vault(&stdout, &vault_name)
|
||||
}
|
||||
_ => IndexStatus {
|
||||
available: false,
|
||||
qmd_installed: true,
|
||||
collection_exists: false,
|
||||
indexed_count: 0,
|
||||
embedded_count: 0,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: None,
|
||||
last_indexed_at: None,
|
||||
},
|
||||
};
|
||||
|
||||
status.last_indexed_commit = meta.last_indexed_commit;
|
||||
status.last_indexed_at = meta.last_indexed_at;
|
||||
status
|
||||
}
|
||||
|
||||
fn vault_dir_name(vault_path: &str) -> String {
|
||||
Path::new(vault_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("laputa")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus {
|
||||
let mut collection_exists = false;
|
||||
let mut indexed_count = 0;
|
||||
let mut embedded_count = 0;
|
||||
let mut pending_embed = 0;
|
||||
|
||||
// Look for collection section matching vault name
|
||||
let mut in_vault_section = false;
|
||||
for line in status_output.lines() {
|
||||
let trimmed = line.trim();
|
||||
// Collection headers look like: " laputa (qmd://laputa/)"
|
||||
if trimmed.contains(&format!("qmd://{vault_name}/")) {
|
||||
collection_exists = true;
|
||||
in_vault_section = true;
|
||||
continue;
|
||||
}
|
||||
// New collection section starts
|
||||
if trimmed.contains("qmd://") && !trimmed.contains(vault_name) {
|
||||
in_vault_section = false;
|
||||
continue;
|
||||
}
|
||||
if in_vault_section {
|
||||
if let Some(count_str) = extract_count_from_line(trimmed, "Files:") {
|
||||
indexed_count = count_str;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global counts from the Documents section
|
||||
for line in status_output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("Total:") {
|
||||
if let Some(n) = extract_first_number(trimmed) {
|
||||
if embedded_count == 0 && indexed_count == 0 {
|
||||
indexed_count = n;
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("Vectors:") {
|
||||
if let Some(n) = extract_first_number(trimmed) {
|
||||
embedded_count = n;
|
||||
}
|
||||
} else if trimmed.starts_with("Pending:") {
|
||||
if let Some(n) = extract_first_number(trimmed) {
|
||||
pending_embed = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IndexStatus {
|
||||
available: true,
|
||||
qmd_installed: true,
|
||||
collection_exists,
|
||||
indexed_count,
|
||||
embedded_count,
|
||||
pending_embed,
|
||||
last_indexed_commit: None,
|
||||
last_indexed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_count_from_line(line: &str, prefix: &str) -> Option<usize> {
|
||||
if !line.starts_with(prefix) {
|
||||
return None;
|
||||
}
|
||||
extract_first_number(line)
|
||||
}
|
||||
|
||||
fn extract_first_number(s: &str) -> Option<usize> {
|
||||
s.split_whitespace()
|
||||
.find_map(|word| word.parse::<usize>().ok())
|
||||
}
|
||||
|
||||
/// Ensure a qmd collection exists for this vault. Creates one if missing.
|
||||
pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
|
||||
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
|
||||
// Check if collection already exists
|
||||
let output = qmd
|
||||
.command()
|
||||
.args(["collection", "list"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to list collections: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.contains(&format!("qmd://{vault_name}/")) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Create collection
|
||||
qmd.command()
|
||||
.args([
|
||||
"collection",
|
||||
"add",
|
||||
vault_path,
|
||||
"--name",
|
||||
&vault_name,
|
||||
"--mask",
|
||||
"**/*.md",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to create collection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct IndexingProgress {
|
||||
pub phase: String,
|
||||
pub current: usize,
|
||||
pub total: usize,
|
||||
pub done: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Run full indexing: update + embed. Returns progress updates via callback.
|
||||
pub fn run_full_index<F>(vault_path: &str, on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(IndexingProgress),
|
||||
{
|
||||
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
|
||||
|
||||
ensure_collection(vault_path)?;
|
||||
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
|
||||
// Phase 1: update (scan files) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "scanning".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
});
|
||||
|
||||
let update_output = qmd
|
||||
.command()
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd update failed: {e}"))?;
|
||||
|
||||
if !update_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&update_output.stderr);
|
||||
let err = format!("qmd update failed: {stderr}");
|
||||
on_progress(IndexingProgress {
|
||||
phase: "error".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some(err.clone()),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Parse update output for counts
|
||||
let update_stdout = String::from_utf8_lossy(&update_output.stdout);
|
||||
let total = parse_indexed_count(&update_stdout);
|
||||
|
||||
on_progress(IndexingProgress {
|
||||
phase: "scanning".to_string(),
|
||||
current: total,
|
||||
total,
|
||||
done: false,
|
||||
error: None,
|
||||
});
|
||||
|
||||
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "embedding".to_string(),
|
||||
current: 0,
|
||||
total,
|
||||
done: false,
|
||||
error: None,
|
||||
});
|
||||
|
||||
let embed_output = qmd
|
||||
.command()
|
||||
.args(["embed", "-c", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd embed failed: {e}"))?;
|
||||
|
||||
if !embed_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&embed_output.stderr);
|
||||
// Embedding failure is non-fatal — keyword search still works
|
||||
log::warn!("qmd embed failed (keyword search still works): {stderr}");
|
||||
stamp_index_commit(vault_path);
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
total,
|
||||
done: true,
|
||||
error: Some("Embedding failed — keyword search only".to_string()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
stamp_index_commit(vault_path);
|
||||
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
total,
|
||||
done: true,
|
||||
error: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_indexed_count(update_output: &str) -> usize {
|
||||
// qmd update output typically contains lines like "Indexed 9078 files"
|
||||
for line in update_output.lines() {
|
||||
if let Some(n) = extract_first_number(line) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Run incremental update for a single file change.
|
||||
pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
|
||||
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
|
||||
|
||||
// Verify collection exists
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
let list_output = qmd
|
||||
.command()
|
||||
.args(["collection", "list"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to list collections: {e}"))?;
|
||||
|
||||
if list_output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&list_output.stdout);
|
||||
if !stdout.contains(&format!("qmd://{vault_name}/")) {
|
||||
// Collection doesn't exist yet — skip incremental, full index needed
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let output = qmd
|
||||
.command()
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("qmd update failed: {stderr}"));
|
||||
}
|
||||
|
||||
stamp_index_commit(vault_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if HEAD has advanced past the last indexed commit.
|
||||
#[cfg(test)]
|
||||
fn needs_reindex_after_sync(vault_path: &str) -> bool {
|
||||
let meta = load_index_metadata(vault_path);
|
||||
let head = get_head_commit(vault_path);
|
||||
match (meta.last_indexed_commit, head) {
|
||||
(Some(last), Some(current)) => last != current,
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn qmd_binary_command_sets_work_dir() {
|
||||
let qmd = QmdBinary {
|
||||
path: "/bin/echo".to_string(),
|
||||
work_dir: Some(PathBuf::from("/tmp")),
|
||||
};
|
||||
let cmd = qmd.command();
|
||||
let dbg = format!("{:?}", cmd);
|
||||
assert!(dbg.contains("/bin/echo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qmd_binary_command_no_work_dir() {
|
||||
let qmd = QmdBinary {
|
||||
path: "/bin/echo".to_string(),
|
||||
work_dir: None,
|
||||
};
|
||||
let output = qmd.command().arg("hello").output().unwrap();
|
||||
assert!(output.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_dir_name_extracts_last_segment() {
|
||||
assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa");
|
||||
assert_eq!(vault_dir_name("/home/user/MyVault"), "myvault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_dir_name_fallback() {
|
||||
assert_eq!(vault_dir_name(""), "laputa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_first_number_works() {
|
||||
assert_eq!(
|
||||
extract_first_number("Total: 9078 files indexed"),
|
||||
Some(9078)
|
||||
);
|
||||
assert_eq!(extract_first_number("Vectors: 14676 embedded"), Some(14676));
|
||||
assert_eq!(extract_first_number("no numbers here"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_finds_collection() {
|
||||
let status = r#"
|
||||
QMD Status
|
||||
|
||||
Index: /Users/luca/.cache/qmd/index.sqlite
|
||||
Size: 100.9 MB
|
||||
|
||||
Documents
|
||||
Total: 9115 files indexed
|
||||
Vectors: 14676 embedded
|
||||
Pending: 26 need embedding
|
||||
|
||||
Collections
|
||||
laputa (qmd://laputa/)
|
||||
Pattern: **/*.md
|
||||
Files: 9078 (updated 20d ago)
|
||||
"#;
|
||||
let result = parse_status_for_vault(status, "laputa");
|
||||
assert!(result.collection_exists);
|
||||
assert_eq!(result.indexed_count, 9078);
|
||||
assert_eq!(result.embedded_count, 14676);
|
||||
assert_eq!(result.pending_embed, 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_missing_collection() {
|
||||
let status = r#"
|
||||
QMD Status
|
||||
|
||||
Documents
|
||||
Total: 100 files indexed
|
||||
Vectors: 50 embedded
|
||||
Pending: 0
|
||||
|
||||
Collections
|
||||
other (qmd://other/)
|
||||
Files: 100
|
||||
"#;
|
||||
let result = parse_status_for_vault(status, "laputa");
|
||||
assert!(!result.collection_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_count_from_line_works() {
|
||||
assert_eq!(
|
||||
extract_count_from_line("Files: 9078 (updated 20d ago)", "Files:"),
|
||||
Some(9078)
|
||||
);
|
||||
assert_eq!(extract_count_from_line("Pattern: **/*.md", "Files:"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_indexed_count_from_output() {
|
||||
assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342);
|
||||
assert_eq!(parse_indexed_count("No output"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_executable_sets_permission() {
|
||||
use std::fs;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test-bin");
|
||||
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
|
||||
|
||||
// Start with no execute permission
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
|
||||
|
||||
ensure_executable(&file);
|
||||
assert_ne!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_executable_noop_when_already_executable() {
|
||||
use std::fs;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test-bin");
|
||||
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
ensure_executable(&file);
|
||||
let mode = fs::metadata(&file).unwrap().permissions().mode();
|
||||
assert_ne!(mode & 0o111, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_bundled_dir_returns_none_for_missing_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(prepare_bundled_dir(dir.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_bundled_dir_finds_and_prepares_binary() {
|
||||
use std::fs;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let qmd_path = dir.path().join("qmd");
|
||||
fs::write(&qmd_path, "#!/bin/sh\necho ok").unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&qmd_path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
}
|
||||
|
||||
let result = prepare_bundled_dir(dir.path());
|
||||
assert!(result.is_some());
|
||||
let bin = result.unwrap();
|
||||
assert!(bin.path.ends_with("qmd"));
|
||||
assert_eq!(bin.work_dir.unwrap(), dir.path());
|
||||
|
||||
// Verify execute permission was set
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_ne!(
|
||||
fs::metadata(&qmd_path).unwrap().permissions().mode() & 0o111,
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bun_returns_some_if_available() {
|
||||
// This test may succeed or fail depending on the system.
|
||||
// It verifies the function doesn't panic.
|
||||
let _ = find_bun();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_metadata_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Default when no file exists
|
||||
let meta = load_index_metadata(vault);
|
||||
assert!(meta.last_indexed_commit.is_none());
|
||||
assert!(meta.last_indexed_at.is_none());
|
||||
|
||||
// Write and read back
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("abc123def456".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
let loaded = load_index_metadata(vault);
|
||||
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
|
||||
assert_eq!(loaded.last_indexed_at, Some(1709000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_metadata_survives_malformed_json() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Write garbage
|
||||
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
|
||||
|
||||
let meta = load_index_metadata(vault);
|
||||
assert!(meta.last_indexed_commit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_no_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Init a git repo so get_head_commit works
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// No metadata → needs reindex
|
||||
assert!(needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_same_commit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let head = get_head_commit(vault).unwrap();
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some(head),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
assert!(!needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_different_commit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("old_commit_hash".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
assert!(needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_index_status_includes_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("abc123".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
let status = check_index_status(vault);
|
||||
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
|
||||
assert_eq!(status.last_indexed_at, Some(1709000000));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod indexing;
|
||||
pub mod mcp;
|
||||
pub mod menu;
|
||||
pub mod search;
|
||||
@@ -149,9 +148,6 @@ pub fn run() {
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::get_index_status,
|
||||
commands::start_indexing,
|
||||
commands::trigger_incremental_index,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
|
||||
@@ -51,7 +51,6 @@ const VAULT_PULL: &str = "vault-pull";
|
||||
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
const VAULT_REINDEX: &str = "vault-reindex";
|
||||
const VAULT_RELOAD: &str = "vault-reload";
|
||||
const VAULT_REPAIR: &str = "vault-repair";
|
||||
|
||||
@@ -96,7 +95,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_RELOAD,
|
||||
VAULT_REPAIR,
|
||||
];
|
||||
@@ -358,9 +356,6 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
|
||||
.id(VAULT_INSTALL_MCP)
|
||||
.build(app)?;
|
||||
let reindex = MenuItemBuilder::new("Reindex Vault")
|
||||
.id(VAULT_REINDEX)
|
||||
.build(app)?;
|
||||
let reload = MenuItemBuilder::new("Reload Vault")
|
||||
.id(VAULT_RELOAD)
|
||||
.build(app)?;
|
||||
@@ -378,7 +373,6 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&resolve_conflicts)
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
.item(&reindex)
|
||||
.item(&reload)
|
||||
.item(&repair)
|
||||
.item(&install_mcp)
|
||||
@@ -499,7 +493,6 @@ mod tests {
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_RELOAD,
|
||||
];
|
||||
for id in &expected {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use crate::indexing;
|
||||
use crate::vault;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
@@ -23,159 +21,108 @@ pub struct SearchResponse {
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QmdResult {
|
||||
pub file: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
|
||||
// qmd://laputa/essay/foo.md → essay/foo.md
|
||||
let relative = uri
|
||||
.strip_prefix("qmd://")
|
||||
.and_then(|s| s.find('/').map(|i| &s[i + 1..]))
|
||||
.unwrap_or(uri);
|
||||
format!("{}/{}", vault_path, relative)
|
||||
}
|
||||
|
||||
fn extract_clean_snippet(raw_snippet: &str) -> String {
|
||||
// qmd snippets start with "@@ -N,N @@ (N before, N after)\n"
|
||||
// We want just the content lines
|
||||
let lines: Vec<&str> = raw_snippet.lines().collect();
|
||||
let content_start = lines.iter().position(|l| !l.starts_with("@@")).unwrap_or(0);
|
||||
let content: String = lines[content_start..]
|
||||
.iter()
|
||||
.filter(|l| !l.starts_with("---"))
|
||||
.take(3)
|
||||
.copied()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
// Trim to reasonable length
|
||||
if content.len() > 200 {
|
||||
format!("{}...", &content[..200])
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
static COLLECTION_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
|
||||
|
||||
fn detect_collection_name(vault_path: &str) -> String {
|
||||
// Check cache first
|
||||
if let Ok(guard) = COLLECTION_CACHE.lock() {
|
||||
if let Some(ref cache) = *guard {
|
||||
if let Some(name) = cache.get(vault_path) {
|
||||
return name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = detect_collection_name_uncached(vault_path);
|
||||
|
||||
// Store in cache
|
||||
if let Ok(mut guard) = COLLECTION_CACHE.lock() {
|
||||
let cache = guard.get_or_insert_with(HashMap::new);
|
||||
cache.insert(vault_path.to_string(), result.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn detect_collection_name_uncached(vault_path: &str) -> String {
|
||||
let qmd = match indexing::find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => return "laputa".to_string(),
|
||||
fn extract_snippet(content: &str, query_lower: &str) -> String {
|
||||
let content_lower = content.to_lowercase();
|
||||
let pos = match content_lower.find(query_lower) {
|
||||
Some(p) => p,
|
||||
None => return String::new(),
|
||||
};
|
||||
|
||||
let output = qmd.command().args(["collection", "list"]).output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let vault_name = Path::new(vault_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("laputa")
|
||||
.to_lowercase();
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&vault_name) && trimmed.contains("qmd://") {
|
||||
if let Some(name) = trimmed.split_whitespace().next() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
vault_name
|
||||
}
|
||||
_ => Path::new(vault_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("laputa")
|
||||
.to_lowercase(),
|
||||
let start = content[..pos]
|
||||
.rfind('\n')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(pos.saturating_sub(60));
|
||||
let end = content[pos..]
|
||||
.find('\n')
|
||||
.map(|i| pos + i)
|
||||
.unwrap_or_else(|| (pos + 120).min(content.len()));
|
||||
let snippet = &content[start..end];
|
||||
if snippet.len() > 200 {
|
||||
format!("{}…", &snippet[..200])
|
||||
} else {
|
||||
snippet.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn score_match(title_lower: &str, content_lower: &str, query_lower: &str) -> f64 {
|
||||
let title_exact = title_lower.contains(query_lower);
|
||||
let title_word = title_lower
|
||||
.split_whitespace()
|
||||
.any(|w| w == query_lower);
|
||||
let content_count = content_lower.matches(query_lower).count();
|
||||
|
||||
let mut score = 0.0;
|
||||
if title_word {
|
||||
score += 10.0;
|
||||
} else if title_exact {
|
||||
score += 5.0;
|
||||
}
|
||||
score += (content_count as f64).min(20.0) * 0.5;
|
||||
score
|
||||
}
|
||||
|
||||
pub fn search_vault(
|
||||
vault_path: &str,
|
||||
query: &str,
|
||||
mode: &str,
|
||||
_mode: &str,
|
||||
limit: usize,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let start = Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
let vault_dir = Path::new(vault_path);
|
||||
|
||||
let qmd = indexing::find_qmd_binary().ok_or_else(|| "qmd binary not found".to_string())?;
|
||||
let mut results: Vec<SearchResult> = Vec::new();
|
||||
|
||||
let collection = detect_collection_name(vault_path);
|
||||
for entry in WalkDir::new(vault_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
continue;
|
||||
}
|
||||
if vault::is_file_trashed(path) {
|
||||
continue;
|
||||
}
|
||||
// Skip hidden dirs and .laputa config
|
||||
if path
|
||||
.components()
|
||||
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let search_cmd = match mode {
|
||||
"semantic" => "vsearch",
|
||||
"hybrid" => "query",
|
||||
_ => "search", // "keyword" default
|
||||
};
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let output = qmd
|
||||
.command()
|
||||
.args([
|
||||
search_cmd,
|
||||
query,
|
||||
"--collection",
|
||||
&collection,
|
||||
"--json",
|
||||
"-n",
|
||||
&limit_str,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run qmd: {}", e))?;
|
||||
let content_lower = content.to_lowercase();
|
||||
let title = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let title_lower = title.to_lowercase();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("qmd search failed: {}", stderr));
|
||||
if !title_lower.contains(&query_lower) && !content_lower.contains(&query_lower) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let score = score_match(&title_lower, &content_lower, &query_lower);
|
||||
let snippet = extract_snippet(&content, &query_lower);
|
||||
let full_path = path.to_string_lossy().to_string();
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
path: full_path,
|
||||
snippet,
|
||||
score,
|
||||
note_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let qmd_results: Vec<QmdResult> =
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse qmd output: {}", e))?;
|
||||
|
||||
let results: Vec<SearchResult> = qmd_results
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let path = qmd_uri_to_vault_path(&r.file, vault_path);
|
||||
if vault::is_file_trashed(Path::new(&path)) {
|
||||
return None;
|
||||
}
|
||||
let snippet = extract_clean_snippet(&r.snippet);
|
||||
Some(SearchResult {
|
||||
title: r.title,
|
||||
path,
|
||||
snippet,
|
||||
score: r.score,
|
||||
note_type: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(limit);
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -183,7 +130,7 @@ pub fn search_vault(
|
||||
results,
|
||||
elapsed_ms,
|
||||
query: query.to_string(),
|
||||
mode: mode.to_string(),
|
||||
mode: "keyword".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,59 +139,36 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qmd_uri_to_vault_path() {
|
||||
assert_eq!(
|
||||
qmd_uri_to_vault_path("qmd://laputa/essay/foo.md", "/Users/luca/Laputa"),
|
||||
"/Users/luca/Laputa/essay/foo.md"
|
||||
);
|
||||
assert_eq!(
|
||||
qmd_uri_to_vault_path(
|
||||
"qmd://laputa/event/2025-10-15-retreat.md",
|
||||
"/Users/luca/Laputa"
|
||||
),
|
||||
"/Users/luca/Laputa/event/2025-10-15-retreat.md"
|
||||
);
|
||||
fn test_extract_snippet_basic() {
|
||||
let content = "line one\nline with keyword here\nline three";
|
||||
let snippet = extract_snippet(content, "keyword");
|
||||
assert!(snippet.contains("keyword"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_clean_snippet() {
|
||||
let raw = "@@ -2,4 @@ (1 before, 9 after)\naliases:\n - \"Refactoring Retreat\"\n\"Is A\":\n - Event";
|
||||
let clean = extract_clean_snippet(raw);
|
||||
assert!(clean.starts_with("aliases:"));
|
||||
assert!(clean.contains("Refactoring Retreat"));
|
||||
fn test_extract_snippet_no_match() {
|
||||
let snippet = extract_snippet("nothing here", "missing");
|
||||
assert!(snippet.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_clean_snippet_long() {
|
||||
let raw = format!("@@ -1,1 @@\n{}", "a".repeat(300));
|
||||
let clean = extract_clean_snippet(&raw);
|
||||
assert!(clean.len() <= 203); // 200 + "..."
|
||||
assert!(clean.ends_with("..."));
|
||||
fn test_score_match_title_word() {
|
||||
let score = score_match("my keyword", "", "keyword");
|
||||
assert!(score >= 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_collection_fallback() {
|
||||
// With a non-existent vault path, should return either the lowercase dir name
|
||||
// (if qmd is available and collection list succeeds) or "laputa" (if qmd is not installed).
|
||||
// Both are valid fallbacks — this test verifies the function doesn't panic.
|
||||
let name = detect_collection_name("/tmp/test-vault");
|
||||
assert!(
|
||||
name == "test-vault" || name == "laputa",
|
||||
"Expected 'test-vault' or 'laputa', got '{}'",
|
||||
name
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_qmd_uri_fallback() {
|
||||
// Covers fallback branch when URI doesn't start with "qmd://"
|
||||
let result = qmd_uri_to_vault_path("invalid-uri", "/vault");
|
||||
assert!(result.contains("invalid-uri"));
|
||||
fn test_score_match_content_only() {
|
||||
let score = score_match("unrelated", "some keyword text keyword", "keyword");
|
||||
assert!(score > 0.0);
|
||||
assert!(score < 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_clean_snippet_no_header() {
|
||||
// No @@ header — content_start = 0
|
||||
let snippet = extract_clean_snippet("plain content line");
|
||||
assert_eq!(snippet, "plain content line");
|
||||
fn test_extract_snippet_long() {
|
||||
let long_line = "a".repeat(300);
|
||||
let content = format!("start\n{}keyword{}\nend", long_line, long_line);
|
||||
let snippet = extract_snippet(&content, "keyword");
|
||||
assert!(snippet.len() <= 203); // 200 + "…" (3 bytes UTF-8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5202",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp && bash scripts/bundle-qmd.sh"
|
||||
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
@@ -38,8 +38,7 @@
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"resources/mcp-server/**/*": "mcp-server/",
|
||||
"resources/qmd/**/*": "qmd/"
|
||||
"resources/mcp-server/**/*": "mcp-server/"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -30,7 +30,6 @@ import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
import { useConflictResolver } from './hooks/useConflictResolver'
|
||||
import { useIndexing } from './hooks/useIndexing'
|
||||
import { useZoom } from './hooks/useZoom'
|
||||
import { useVaultConfig } from './hooks/useVaultConfig'
|
||||
import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
@@ -98,13 +97,10 @@ function App() {
|
||||
const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault)
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const indexing = useIndexing(resolvedPath)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onSyncUpdated: indexing.triggerIncrementalIndex,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
@@ -294,11 +290,9 @@ function App() {
|
||||
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
|
||||
}, [resolvedPath])
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
triggerIncrementalIndex()
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
}, [vault])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature required by useEditorSave
|
||||
const onNotePersisted = useCallback((path: string, _content: string) => {
|
||||
@@ -507,7 +501,6 @@ function App() {
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReopenClosedTab: notes.handleReopenClosedTab,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -640,7 +633,7 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('SearchPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('performs unified search with keyword then hybrid', async () => {
|
||||
it('performs keyword search', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs for AI...', score: 0.87, note_type: 'Essay' },
|
||||
@@ -140,7 +140,6 @@ describe('SearchPanel', () => {
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
fireEvent.change(input, { target: { value: 'api design' } })
|
||||
|
||||
// Should call keyword search first
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
|
||||
vaultPath: '/vault',
|
||||
@@ -150,20 +149,9 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Results should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should also call hybrid search
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
|
||||
vaultPath: '/vault',
|
||||
query: 'api design',
|
||||
mode: 'hybrid',
|
||||
limit: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
@@ -331,7 +319,7 @@ describe('SearchPanel', () => {
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
|
||||
// Spinner appears when keyword search starts (after debounce)
|
||||
// Spinner appears when search starts (after debounce)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
|
||||
})
|
||||
@@ -342,21 +330,9 @@ describe('SearchPanel', () => {
|
||||
elapsed_ms: 30,
|
||||
})
|
||||
|
||||
// Keyword results appear, spinner still visible (hybrid in progress)
|
||||
// Spinner disappears after search completes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for hybrid call then resolve it
|
||||
await waitFor(() => { expect(resolvers).toHaveLength(2) })
|
||||
resolvers[1]({
|
||||
results: [{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 150,
|
||||
})
|
||||
|
||||
// Spinner disappears after hybrid completes
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -388,44 +364,12 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps keyword results when hybrid search fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (_cmd: string, args?: Record<string, unknown>) => {
|
||||
const mode = (args as Record<string, string>)?.mode
|
||||
if (mode === 'keyword') {
|
||||
return {
|
||||
results: [{ title: 'Keyword Only', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 30,
|
||||
}
|
||||
}
|
||||
throw new Error('qmd unavailable')
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
|
||||
// Keyword results appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Spinner disappears after hybrid fails
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Keyword results remain
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deduplicates results when backend returns same note twice', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'keyword hit', score: 0.7, note_type: 'Essay' },
|
||||
{ title: 'Refactoring Retreat', path: '/vault/event/retreat.md', snippet: 'unique', score: 0.6, note_type: 'Event' },
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'semantic hit', score: 0.9, note_type: 'Essay' },
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'duplicate hit', score: 0.9, note_type: 'Essay' },
|
||||
],
|
||||
elapsed_ms: 48,
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import type { VaultOption } from './StatusBar'
|
||||
import { formatIndexedElapsed } from '../utils/indexingHelpers'
|
||||
|
||||
vi.mock('../utils/url', async () => {
|
||||
const actual = await vi.importActual('../utils/url')
|
||||
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
|
||||
@@ -226,121 +224,6 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows indexing badge when indexing is in progress', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'scanning', current: 342, total: 1057, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-indexing')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Indexing… 342\/1,057/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows embedding phase in indexing badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'embedding', current: 50, total: 200, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows index ready when indexing is complete', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'complete', current: 1057, total: 1057, done: true, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Index ready')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state in indexing badge with retry label', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Index failed — retry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexing badge when phase is unavailable', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'unavailable', current: 0, total: 0, done: true, error: 'qmd not available' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRetryIndexing when clicking error badge', () => {
|
||||
const onRetryIndexing = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
|
||||
onRetryIndexing={onRetryIndexing}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-indexing'))
|
||||
expect(onRetryIndexing).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides indexing badge when phase is idle', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexing badge when no progress prop provided', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installing phase in indexing badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'installing', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Installing search…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows MCP warning badge when status is not_installed', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
|
||||
@@ -396,38 +279,6 @@ describe('StatusBar', () => {
|
||||
expect(onInstallMcp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Indexed just now" when lastIndexedTime is recent and phase is idle', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
lastIndexedTime={Date.now() - 5000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onReindexVault when clicking the indexed time badge', () => {
|
||||
const onReindexVault = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
lastIndexedTime={Date.now() - 5000}
|
||||
onReindexVault={onReindexVault}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-indexed-time'))
|
||||
expect(onReindexVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Pull required label when syncStatus is pull_required', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />
|
||||
@@ -462,38 +313,4 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexed time badge when no lastIndexedTime', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexed-time')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIndexedElapsed', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatIndexedElapsed(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns "Indexed just now" for < 60s', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
|
||||
})
|
||||
|
||||
it('returns minutes for < 60min', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
|
||||
})
|
||||
|
||||
it('returns hours for < 24h', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
|
||||
})
|
||||
|
||||
it('returns days for >= 24h', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { formatIndexedElapsed } from '../utils/indexingHelpers'
|
||||
|
||||
export interface VaultOption {
|
||||
label: string
|
||||
@@ -35,10 +33,6 @@ interface StatusBarProps {
|
||||
onZoomReset?: () => void
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
indexingProgress?: IndexingProgress
|
||||
lastIndexedTime?: number | null
|
||||
onRetryIndexing?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
@@ -334,70 +328,6 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
)
|
||||
}
|
||||
|
||||
const INDEXING_LABELS: Record<string, string> = {
|
||||
installing: 'Installing search…',
|
||||
scanning: 'Indexing…',
|
||||
embedding: 'Embedding…',
|
||||
complete: 'Index ready',
|
||||
error: 'Index failed — retry',
|
||||
unavailable: 'Search unavailable',
|
||||
}
|
||||
|
||||
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
|
||||
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
|
||||
|
||||
// When idle, show "Indexed Xm ago" if we have a timestamp
|
||||
if (isIdle) {
|
||||
if (!lastIndexedTime) return null
|
||||
const elapsed = formatIndexedElapsed(lastIndexedTime)
|
||||
if (!elapsed) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={onReindex ? 'button' : undefined}
|
||||
onClick={onReindex}
|
||||
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title={onReindex ? 'Click to reindex vault' : undefined}
|
||||
data-testid="status-indexed-time"
|
||||
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
>
|
||||
<Search size={13} />{elapsed}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
|
||||
const isActive = !progress.done
|
||||
const isError = progress.phase === 'error'
|
||||
const showCount = progress.total > 0 && isActive
|
||||
const displayText = showCount
|
||||
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
|
||||
: label
|
||||
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={isError && onRetry ? 'button' : undefined}
|
||||
onClick={isError && onRetry ? onRetry : undefined}
|
||||
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
|
||||
title={isError ? 'Click to retry indexing' : undefined}
|
||||
data-testid="status-indexing"
|
||||
>
|
||||
{isActive
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Search size={13} />
|
||||
}
|
||||
{displayText}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
@@ -451,7 +381,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -477,7 +407,6 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
||||
@@ -64,7 +64,6 @@ interface AppCommandsConfig {
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReopenClosedTab?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
@@ -156,7 +155,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
@@ -212,7 +210,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
trashedCount: config.trashedCount,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onSetNoteIcon: config.onSetNoteIcon,
|
||||
|
||||
@@ -95,46 +95,6 @@ describe('useCommandRegistry', () => {
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('includes reindex-vault command in Settings group', () => {
|
||||
const config = makeConfig({ onReindexVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.label).toBe('Reindex Vault')
|
||||
})
|
||||
|
||||
it('reindex-vault is enabled when onReindexVault is provided', () => {
|
||||
const config = makeConfig({ onReindexVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('reindex-vault is disabled when onReindexVault is not provided', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reindex-vault executes onReindexVault callback', () => {
|
||||
const onReindexVault = vi.fn()
|
||||
const config = makeConfig({ onReindexVault })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
cmd!.execute()
|
||||
expect(onReindexVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reindex-vault has searchable keywords', () => {
|
||||
const config = makeConfig({ onReindexVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd!.keywords).toContain('reindex')
|
||||
expect(cmd!.keywords).toContain('search')
|
||||
})
|
||||
|
||||
it('resolve-conflicts stays enabled across rerenders', () => {
|
||||
const config = makeConfig()
|
||||
const { result, rerender } = renderHook(
|
||||
|
||||
@@ -25,7 +25,6 @@ interface CommandRegistryConfig {
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
@@ -169,7 +168,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
onSetNoteIcon,
|
||||
@@ -256,7 +254,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
|
||||
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
@@ -282,7 +279,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
onOpenInNewWindow,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useIndexing } from './useIndexing'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(vi.fn()) }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
qmd_installed: true,
|
||||
collection_exists: true,
|
||||
indexed_count: 100,
|
||||
embedded_count: 80,
|
||||
pending_embed: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
|
||||
|
||||
describe('useIndexing', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockInvoke.mockResolvedValue({
|
||||
available: true,
|
||||
qmd_installed: true,
|
||||
collection_exists: true,
|
||||
indexed_count: 100,
|
||||
embedded_count: 80,
|
||||
pending_embed: 0,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with idle phase', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(result.current.progress.phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('auto-dismisses error phase after 15 seconds', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
// Simulate setting error state via retryIndexing
|
||||
mockInvoke.mockRejectedValueOnce(new Error('qmd update failed'))
|
||||
await act(async () => { await result.current.retryIndexing() })
|
||||
expect(result.current.progress.phase).toBe('error')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(15000) })
|
||||
expect(result.current.progress.phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('sets unavailable phase for "not installed" errors', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
|
||||
await act(async () => { await result.current.retryIndexing() })
|
||||
expect(result.current.progress.phase).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('sets unavailable phase for "not available" errors', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
mockInvoke.mockRejectedValueOnce(new Error('qmd not available: bun not found'))
|
||||
await act(async () => { await result.current.retryIndexing() })
|
||||
expect(result.current.progress.phase).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('auto-dismisses unavailable phase after 8 seconds', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
|
||||
await act(async () => { await result.current.retryIndexing() })
|
||||
expect(result.current.progress.phase).toBe('unavailable')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(8000) })
|
||||
expect(result.current.progress.phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('exposes retryIndexing function', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(typeof result.current.retryIndexing).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes triggerFullReindex function', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(typeof result.current.triggerFullReindex).toBe('function')
|
||||
})
|
||||
|
||||
it('retryIndexing is the same reference as triggerFullReindex', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(result.current.retryIndexing).toBe(result.current.triggerFullReindex)
|
||||
})
|
||||
|
||||
it('triggerFullReindex sets scanning phase then completes', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
await act(async () => { await result.current.triggerFullReindex() })
|
||||
// In non-Tauri mode, it goes to 'complete' then auto-dismisses
|
||||
expect(result.current.progress.phase).toBe('complete')
|
||||
})
|
||||
|
||||
it('triggerFullReindex sets lastIndexedTime on success', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
expect(result.current.lastIndexedTime).toBeNull()
|
||||
await act(async () => { await result.current.triggerFullReindex() })
|
||||
expect(result.current.lastIndexedTime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('triggerFullReindex sets error phase on failure', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
mockInvoke.mockRejectedValueOnce(new Error('indexing failed'))
|
||||
await act(async () => { await result.current.triggerFullReindex() })
|
||||
expect(result.current.progress.phase).toBe('error')
|
||||
})
|
||||
|
||||
it('populates lastIndexedTime from backend metadata on mount', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
available: true,
|
||||
qmd_installed: true,
|
||||
collection_exists: true,
|
||||
indexed_count: 100,
|
||||
embedded_count: 80,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: 'abc123',
|
||||
last_indexed_at: 1709800000,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
// Wait for the effect to run
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10) })
|
||||
expect(result.current.lastIndexedTime).toBe(1709800000000) // seconds → ms
|
||||
})
|
||||
|
||||
it('starts with lastIndexedTime as null', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(result.current.lastIndexedTime).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
export interface IndexingProgress {
|
||||
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error' | 'unavailable'
|
||||
current: number
|
||||
total: number
|
||||
done: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface IndexStatus {
|
||||
available: boolean
|
||||
qmd_installed: boolean
|
||||
collection_exists: boolean
|
||||
indexed_count: number
|
||||
embedded_count: number
|
||||
pending_embed: number
|
||||
last_indexed_commit: string | null
|
||||
last_indexed_at: number | null
|
||||
}
|
||||
|
||||
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
|
||||
|
||||
function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
export function useIndexing(vaultPath: string) {
|
||||
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
|
||||
const [lastIndexedTime, setLastIndexedTime] = useState<number | null>(null)
|
||||
const indexingRef = useRef(false)
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
|
||||
// Listen for progress events from Rust
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
import('@tauri-apps/api/event').then(({ listen }) => {
|
||||
const unlisten = listen<IndexingProgress>('indexing-progress', (event) => {
|
||||
setProgress(event.payload)
|
||||
if (event.payload.done) {
|
||||
indexingRef.current = false
|
||||
if (event.payload.phase === 'complete') {
|
||||
setLastIndexedTime(Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
cleanup = () => { unlisten.then(fn => fn()) }
|
||||
})
|
||||
|
||||
return () => { cleanup?.() }
|
||||
}, [])
|
||||
|
||||
// Check index status and auto-trigger indexing on vault open
|
||||
useEffect(() => {
|
||||
if (!vaultPath) return
|
||||
let cancelled = false
|
||||
|
||||
async function checkAndIndex() {
|
||||
try {
|
||||
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
|
||||
if (cancelled) return
|
||||
|
||||
// Populate last indexed time from backend metadata
|
||||
if (status.last_indexed_at) {
|
||||
setLastIndexedTime(status.last_indexed_at * 1000) // seconds → ms
|
||||
}
|
||||
|
||||
// If qmd not installed or no collection or pending embeds, trigger indexing
|
||||
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
|
||||
if (needsIndexing && !indexingRef.current) {
|
||||
indexingRef.current = true
|
||||
setProgress({
|
||||
phase: status.qmd_installed ? 'scanning' : 'installing',
|
||||
current: 0,
|
||||
total: status.indexed_count,
|
||||
done: false,
|
||||
error: null,
|
||||
})
|
||||
// Fire and forget — progress updates come via events
|
||||
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
|
||||
if (cancelled) return
|
||||
const msg = String(err)
|
||||
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
|
||||
setProgress({
|
||||
phase: isUnavailable ? 'unavailable' : 'error',
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: msg,
|
||||
})
|
||||
indexingRef.current = false
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// get_index_status failed — likely qmd not available, non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
checkAndIndex()
|
||||
return () => { cancelled = true }
|
||||
}, [vaultPath])
|
||||
|
||||
// Auto-dismiss transient statuses after a delay
|
||||
useEffect(() => {
|
||||
if (progress.phase === 'complete') {
|
||||
const timer = setTimeout(() => setProgress(IDLE), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (progress.phase === 'unavailable') {
|
||||
const timer = setTimeout(() => setProgress(IDLE), 8000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (progress.phase === 'error') {
|
||||
const timer = setTimeout(() => setProgress(IDLE), 15000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [progress.phase])
|
||||
|
||||
const triggerIncrementalIndex = useCallback(async () => {
|
||||
if (indexingRef.current) return
|
||||
try {
|
||||
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
|
||||
setLastIndexedTime(Date.now())
|
||||
} catch {
|
||||
// Incremental update failure is non-fatal
|
||||
}
|
||||
}, [])
|
||||
|
||||
const triggerFullReindex = useCallback(async () => {
|
||||
if (indexingRef.current || !vaultPathRef.current) return
|
||||
indexingRef.current = true
|
||||
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
|
||||
try {
|
||||
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
|
||||
// In non-Tauri mode, mark complete immediately
|
||||
if (!isTauri()) {
|
||||
setProgress({ phase: 'complete', current: 0, total: 0, done: true, error: null })
|
||||
setLastIndexedTime(Date.now())
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = String(err)
|
||||
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
|
||||
setProgress({ phase: isUnavailable ? 'unavailable' : 'error', current: 0, total: 0, done: true, error: msg })
|
||||
} finally {
|
||||
indexingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const retryIndexing = triggerFullReindex
|
||||
|
||||
return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
|
||||
}
|
||||
@@ -33,7 +33,6 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onReopenClosedTab: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
@@ -296,12 +295,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onInstallMcp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-reindex triggers reindex vault', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-reindex', h)
|
||||
expect(h.onReindexVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-reload triggers reload vault', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-reload', h)
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface MenuEventHandlers {
|
||||
onInstallMcp?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
@@ -82,7 +81,7 @@ type OptionalHandler =
|
||||
| 'onGoBack' | 'onGoForward' | 'onCheckForUpdates'
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onEmptyTrash'
|
||||
| 'onReopenClosedTab'
|
||||
| 'onOpenInNewWindow'
|
||||
@@ -103,7 +102,6 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-resolve-conflicts': 'onResolveConflicts',
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-reload': 'onReloadVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
'note-empty-trash': 'onEmptyTrash',
|
||||
|
||||
@@ -17,7 +17,6 @@ interface SearchResponseData {
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
const HYBRID_TIMEOUT_MS = 5000
|
||||
|
||||
function searchCall(args: Record<string, unknown>): Promise<SearchResponseData> {
|
||||
return isTauri()
|
||||
@@ -25,16 +24,6 @@ function searchCall(args: Record<string, unknown>): Promise<SearchResponseData>
|
||||
: mockInvoke<SearchResponseData>('search_vault', args)
|
||||
}
|
||||
|
||||
function searchWithTimeout(args: Record<string, unknown>, ms: number): Promise<SearchResponseData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Search timeout')), ms)
|
||||
searchCall(args).then(
|
||||
result => { clearTimeout(timer); resolve(result) },
|
||||
err => { clearTimeout(timer); reject(err) },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function mapResults(raw: SearchResultData[]): SearchResult[] {
|
||||
const seen = new Set<string>()
|
||||
return raw
|
||||
@@ -92,19 +81,7 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
if (gen !== searchGenRef.current) return
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await searchWithTimeout(
|
||||
{ vaultPath, query: q, mode: 'hybrid', limit: 20 },
|
||||
HYBRID_TIMEOUT_MS,
|
||||
)
|
||||
if (gen !== searchGenRef.current) return
|
||||
setResults(mapResults(response.results))
|
||||
setElapsedMs(response.elapsed_ms)
|
||||
setSelectedIndex(prev => Math.min(prev, Math.max(response.results.length - 1, 0)))
|
||||
} catch { /* Hybrid timed out — keyword results remain */ } finally {
|
||||
} finally {
|
||||
if (gen === searchGenRef.current) setLoading(false)
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
@@ -265,9 +265,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
|
||||
start_indexing: () => null,
|
||||
trigger_incremental_index: () => null,
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export function formatIndexedElapsed(lastIndexedTime: number | null): string {
|
||||
if (!lastIndexedTime) return ''
|
||||
const secs = Math.round((Date.now() - lastIndexedTime) / 1000)
|
||||
if (secs < 60) return 'Indexed just now'
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `Indexed ${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `Indexed ${hrs}h ago`
|
||||
return `Indexed ${Math.floor(hrs / 24)}d ago`
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import { sendShortcut, openCommandPalette, findCommand } from './helpers'
|
||||
* MacBook without pre-configured environment.
|
||||
*
|
||||
* Tasks under test:
|
||||
* 1. Search/qmd bundling + auto-index
|
||||
* 2. MCP server foundation + auto-registration
|
||||
* 1. MCP server foundation + auto-registration
|
||||
* 3. AGENTS.md vault-level instructions
|
||||
* 4. AI Agent panel: vault-native AI
|
||||
* 5. Claude API wiring + full agent loop
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
openCommandPalette,
|
||||
findCommand,
|
||||
executeCommand,
|
||||
} from './helpers'
|
||||
|
||||
test.describe('Reindex Vault smoke tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Reindex Vault command appears in command palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Reindex Vault')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Reindex Vault command triggers indexing progress', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Reindex Vault')
|
||||
|
||||
// After triggering reindex, the indexing badge should appear in the status bar
|
||||
const indexingBadge = page.locator('[data-testid="status-indexing"], [data-testid="status-indexed-time"]')
|
||||
await expect(indexingBadge.first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
@@ -1,637 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "2025-12-07-bm25-q",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"node-llama-cpp": "^3.14.5",
|
||||
"sqlite-vec": "^0.1.7-alpha.2",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sqlite-vec-darwin-arm64": "^0.1.7-alpha.2",
|
||||
"sqlite-vec-darwin-x64": "^0.1.7-alpha.2",
|
||||
"sqlite-vec-linux-x64": "^0.1.7-alpha.2",
|
||||
"sqlite-vec-win32-x64": "^0.1.7-alpha.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.3", "", {}, "sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA=="],
|
||||
|
||||
"@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
|
||||
|
||||
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="],
|
||||
|
||||
"@node-llama-cpp/linux-arm64": ["@node-llama-cpp/linux-arm64@3.14.5", "", { "os": "linux", "cpu": [ "x64", "arm64", ] }, "sha512-58IcWW7EOqc/66mYWXRsoMCy1MR3pTX/YaC0HYF9Rg5XeAPKhUP7NHrglbqgjO62CkcuFZaSEiX2AtG972GQYQ=="],
|
||||
|
||||
"@node-llama-cpp/linux-armv7l": ["@node-llama-cpp/linux-armv7l@3.14.5", "", { "os": "linux", "cpu": [ "arm", "x64", ] }, "sha512-mJWN0qWsn8y+r/34DC3XlSiXjjKs6wX1BTx0wwJ37fWefS/qfzuBJwQGqpfqe5xpfafib/RgQX44fsvE/9yb1w=="],
|
||||
|
||||
"@node-llama-cpp/linux-x64": ["@node-llama-cpp/linux-x64@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-f6xCqlSqSxMP9Iwm3CpaTzFybbHrzpLkNzA18v21PwhMN8u4DP44euLoxe+BMbOpyzx4iMxU1AUsPsgcHD1Y4w=="],
|
||||
|
||||
"@node-llama-cpp/linux-x64-cuda": ["@node-llama-cpp/linux-x64-cuda@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-yk0EGnAJ+m/paSaItigmxcqC8nNjZlkx9yZgQE51CsTip7tmnqqlj60pW1fWmhrjOJ9XnRlVVTP81fa9B+O1Hg=="],
|
||||
|
||||
"@node-llama-cpp/linux-x64-cuda-ext": ["@node-llama-cpp/linux-x64-cuda-ext@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-AACXmXjqvAppoC6Z20UI7yeSZaFb6uP9x/2lzctVwlm42ef76SN6DNXaX1yzH7DTyzK5zYhoH4ycJUe+zOeGzw=="],
|
||||
|
||||
"@node-llama-cpp/linux-x64-vulkan": ["@node-llama-cpp/linux-x64-vulkan@3.14.5", "", { "os": "linux", "cpu": "x64" }, "sha512-9wZG90CUyyO8EsqfDEh03/fK0ctbQFbKaAFa6Goh+jFLOtqPL+plLqAsW3jDFdLRF5+oAPTKt9/4Y7vHTajQbQ=="],
|
||||
|
||||
"@node-llama-cpp/mac-arm64-metal": ["@node-llama-cpp/mac-arm64-metal@3.14.5", "", { "os": "darwin", "cpu": [ "x64", "arm64", ] }, "sha512-7pclj/nbQyx7gPVbyqkCn+ftlGcnw7YrewxBv1/BWWAMzBrMt2+qkjtUcUhwXH7mT5WN/+eWsszhIMXH3Uf6vQ=="],
|
||||
|
||||
"@node-llama-cpp/mac-x64": ["@node-llama-cpp/mac-x64@3.14.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-iZBmLgPkLKiKS0lYAuqq8i85etGeQ9L+AjEJUhG5N6T/vCF4XSOkUTsEFMEX+iJLV3VxvY/C8R1e/UF7InUjUg=="],
|
||||
|
||||
"@node-llama-cpp/win-arm64": ["@node-llama-cpp/win-arm64@3.14.5", "", { "os": "win32", "cpu": [ "x64", "arm64", ] }, "sha512-WTZJeb2JZo/qPNHf++xA2YeMXB46G7G4WsKEnHVyCpAhhslHAhe/LPgSQfNfk9rYusbsRiy9QMxeGNSOowZMVQ=="],
|
||||
|
||||
"@node-llama-cpp/win-x64": ["@node-llama-cpp/win-x64@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-cEuhb1iLTodM+V8xc1mWKeWRYkX9tlnl0+9jUjwsv2kgnAjEob3WlTYsCXewvEe2ShSyk8AsLsBPZxv7IQaBsw=="],
|
||||
|
||||
"@node-llama-cpp/win-x64-cuda": ["@node-llama-cpp/win-x64-cuda@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-gwBMSzUteLD765Gq/hYQ4UC21vggR7oG+DU4zAg0Mt3i34PqKJC+tBop5jsTN5Hq8RaM9+nTNrVbF/x228TLvg=="],
|
||||
|
||||
"@node-llama-cpp/win-x64-cuda-ext": ["@node-llama-cpp/win-x64-cuda-ext@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-kBHnUmodr+n8N+sKTh1c6aNNEmvXBWM5AtaLWIEfkCb00bVHNFeqYPmLuPNtMX3dIUtD9PHdA4Jsn0RJmNZJfA=="],
|
||||
|
||||
"@node-llama-cpp/win-x64-vulkan": ["@node-llama-cpp/win-x64-vulkan@3.14.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+vr5RaGSCWEe22WZMkhUu16o9zpeqTZO/nD5G27Y0bb+xBRDLmXbxYMp2dDQTfpkNWIZ0ia3PGWwl5yhYw7A=="],
|
||||
|
||||
"@octokit/app": ["@octokit/app@16.1.2", "", { "dependencies": { "@octokit/auth-app": "^8.1.2", "@octokit/auth-unauthenticated": "^7.0.3", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ=="],
|
||||
|
||||
"@octokit/auth-app": ["@octokit/auth-app@8.1.2", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-db8VO0PqXxfzI6GdjtgEFHY9tzqUql5xMFXYA12juq8TeTgPAuiiP3zid4h50lwlIP457p5+56PnJOgd2GGBuw=="],
|
||||
|
||||
"@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="],
|
||||
|
||||
"@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="],
|
||||
|
||||
"@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@7.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
|
||||
|
||||
"@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="],
|
||||
|
||||
"@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
|
||||
|
||||
"@octokit/oauth-app": ["@octokit/oauth-app@8.0.3", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", "@octokit/auth-oauth-user": "^6.0.1", "@octokit/auth-unauthenticated": "^7.0.2", "@octokit/core": "^7.0.5", "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/oauth-methods": "^6.0.1", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg=="],
|
||||
|
||||
"@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="],
|
||||
|
||||
"@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="],
|
||||
|
||||
"@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="],
|
||||
|
||||
"@octokit/plugin-paginate-graphql": ["@octokit/plugin-paginate-graphql@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
|
||||
|
||||
"@octokit/plugin-retry": ["@octokit/plugin-retry@8.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA=="],
|
||||
|
||||
"@octokit/plugin-throttling": ["@octokit/plugin-throttling@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^7.0.0" } }, "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg=="],
|
||||
|
||||
"@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
|
||||
|
||||
"@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="],
|
||||
|
||||
"@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="],
|
||||
|
||||
"@reflink/reflink": ["@reflink/reflink@0.1.19", "", { "optionalDependencies": { "@reflink/reflink-darwin-arm64": "0.1.19", "@reflink/reflink-darwin-x64": "0.1.19", "@reflink/reflink-linux-arm64-gnu": "0.1.19", "@reflink/reflink-linux-arm64-musl": "0.1.19", "@reflink/reflink-linux-x64-gnu": "0.1.19", "@reflink/reflink-linux-x64-musl": "0.1.19", "@reflink/reflink-win32-arm64-msvc": "0.1.19", "@reflink/reflink-win32-x64-msvc": "0.1.19" } }, "sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA=="],
|
||||
|
||||
"@reflink/reflink-darwin-arm64": ["@reflink/reflink-darwin-arm64@0.1.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA=="],
|
||||
|
||||
"@reflink/reflink-darwin-x64": ["@reflink/reflink-darwin-x64@0.1.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA=="],
|
||||
|
||||
"@reflink/reflink-linux-arm64-gnu": ["@reflink/reflink-linux-arm64-gnu@0.1.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg=="],
|
||||
|
||||
"@reflink/reflink-linux-arm64-musl": ["@reflink/reflink-linux-arm64-musl@0.1.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA=="],
|
||||
|
||||
"@reflink/reflink-linux-x64-gnu": ["@reflink/reflink-linux-x64-gnu@0.1.19", "", { "os": "linux", "cpu": "x64" }, "sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw=="],
|
||||
|
||||
"@reflink/reflink-linux-x64-musl": ["@reflink/reflink-linux-x64-musl@0.1.19", "", { "os": "linux", "cpu": "x64" }, "sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ=="],
|
||||
|
||||
"@reflink/reflink-win32-arm64-msvc": ["@reflink/reflink-win32-arm64-msvc@0.1.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ=="],
|
||||
|
||||
"@reflink/reflink-win32-x64-msvc": ["@reflink/reflink-win32-x64-msvc@0.1.19", "", { "os": "win32", "cpu": "x64" }, "sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w=="],
|
||||
|
||||
"@tinyhttp/content-disposition": ["@tinyhttp/content-disposition@2.2.2", "", {}, "sha512-crXw1txzrS36huQOyQGYFvhTeLeG0Si1xu+/l6kXUVYpE0TjFjEZRqTbuadQLfKGZ0jaI+jJoRyqaWwxOSHW2g=="],
|
||||
|
||||
"@types/aws-lambda": ["@types/aws-lambda@8.10.159", "", {}, "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="],
|
||||
|
||||
"are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="],
|
||||
|
||||
"async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chmodrp": ["chmodrp@1.0.2", "", {}, "sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w=="],
|
||||
|
||||
"chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
|
||||
|
||||
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"cmake-js": ["cmake-js@7.4.0", "", { "dependencies": { "axios": "^1.6.5", "debug": "^4", "fs-extra": "^11.2.0", "memory-stream": "^1.0.0", "node-api-headers": "^1.1.0", "npmlog": "^6.0.2", "rc": "^1.2.7", "semver": "^7.5.4", "tar": "^6.2.0", "url-join": "^4.0.1", "which": "^2.0.2", "yargs": "^17.7.2" }, "bin": { "cmake-js": "bin/cmake-js" } }, "sha512-Lw0JxEHrmk+qNj1n9W9d4IvkDdYTBn7l2BW6XmtLj7WPpIo2shvxUy+YokfjMxAAOELNonQwX3stkPhM5xSC2Q=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"env-var": ["env-var@7.5.0", "", {}, "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"filename-reserved-regex": ["filename-reserved-regex@3.0.0", "", {}, "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw=="],
|
||||
|
||||
"filenamify": ["filenamify@6.0.0", "", { "dependencies": { "filename-reserved-regex": "^3.0.0" } }, "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "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" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
|
||||
|
||||
"fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.11.1", "", {}, "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"ipull": ["ipull@3.9.3", "", { "dependencies": { "@tinyhttp/content-disposition": "^2.2.0", "async-retry": "^1.3.3", "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-spinners": "^2.9.2", "commander": "^10.0.0", "eventemitter3": "^5.0.1", "filenamify": "^6.0.0", "fs-extra": "^11.1.1", "is-unicode-supported": "^2.0.0", "lifecycle-utils": "^2.0.1", "lodash.debounce": "^4.0.8", "lowdb": "^7.0.1", "pretty-bytes": "^6.1.0", "pretty-ms": "^8.0.0", "sleep-promise": "^9.1.0", "slice-ansi": "^7.1.0", "stdout-update": "^4.0.1", "strip-ansi": "^7.1.0" }, "optionalDependencies": { "@reflink/reflink": "^0.1.16" }, "bin": { "ipull": "dist/cli/cli.js" } }, "sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
|
||||
"lifecycle-utils": ["lifecycle-utils@3.0.1", "", {}, "sha512-Qt/Jl5dsNIsyCAZsHB6x3mbwHFn0HJbdmvF49sVX/bHgX2cW7+G+U+I67Zw+TPM1Sr21Gb2nfJMd2g6iUcI1EQ=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="],
|
||||
|
||||
"lowdb": ["lowdb@7.0.1", "", { "dependencies": { "steno": "^4.0.2" } }, "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"memory-stream": ["memory-stream@1.0.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
|
||||
|
||||
"minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
|
||||
|
||||
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
||||
|
||||
"node-api-headers": ["node-api-headers@1.7.0", "", {}, "sha512-uJMGdkhVwu9+I3UsVvI3KW6ICAy/yDfsu5Br9rSnTtY3WpoaComXvKloiV5wtx0Md2rn0B9n29Ys2WMNwWxj9A=="],
|
||||
|
||||
"node-llama-cpp": ["node-llama-cpp@3.14.5", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "async-retry": "^1.3.3", "bytes": "^3.1.2", "chalk": "^5.4.1", "chmodrp": "^1.0.2", "cmake-js": "^7.4.0", "cross-spawn": "^7.0.6", "env-var": "^7.5.0", "filenamify": "^6.0.0", "fs-extra": "^11.3.0", "ignore": "^7.0.4", "ipull": "^3.9.2", "is-unicode-supported": "^2.1.0", "lifecycle-utils": "^3.0.1", "log-symbols": "^7.0.0", "nanoid": "^5.1.5", "node-addon-api": "^8.3.1", "octokit": "^5.0.3", "ora": "^8.2.0", "pretty-ms": "^9.2.0", "proper-lockfile": "^4.1.2", "semver": "^7.7.1", "simple-git": "^3.27.0", "slice-ansi": "^7.1.0", "stdout-update": "^4.0.1", "strip-ansi": "^7.1.0", "validate-npm-package-name": "^6.0.0", "which": "^5.0.0", "yargs": "^17.7.2" }, "optionalDependencies": { "@node-llama-cpp/linux-arm64": "3.14.5", "@node-llama-cpp/linux-armv7l": "3.14.5", "@node-llama-cpp/linux-x64": "3.14.5", "@node-llama-cpp/linux-x64-cuda": "3.14.5", "@node-llama-cpp/linux-x64-cuda-ext": "3.14.5", "@node-llama-cpp/linux-x64-vulkan": "3.14.5", "@node-llama-cpp/mac-arm64-metal": "3.14.5", "@node-llama-cpp/mac-x64": "3.14.5", "@node-llama-cpp/win-arm64": "3.14.5", "@node-llama-cpp/win-x64": "3.14.5", "@node-llama-cpp/win-x64-cuda": "3.14.5", "@node-llama-cpp/win-x64-cuda-ext": "3.14.5", "@node-llama-cpp/win-x64-vulkan": "3.14.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"], "bin": { "node-llama-cpp": "dist/cli/cli.js", "nlc": "dist/cli/cli.js" } }, "sha512-Db+RFqFMJOOVWprUINq77LVe44FaiJ6JvNiq14r2+DZRgkgyxckSZa6DcZ5Xe5MC+hGA5aqOdnNxsrudUcs74Q=="],
|
||||
|
||||
"npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"octokit": ["octokit@5.0.5", "", { "dependencies": { "@octokit/app": "^16.1.2", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-graphql": "^6.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", "@octokit/plugin-retry": "^8.0.3", "@octokit/plugin-throttling": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="],
|
||||
|
||||
"sleep-promise": ["sleep-promise@9.1.0", "", {}, "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"sqlite-vec": ["sqlite-vec@0.1.7-alpha.2", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.7-alpha.2", "sqlite-vec-darwin-x64": "0.1.7-alpha.2", "sqlite-vec-linux-arm64": "0.1.7-alpha.2", "sqlite-vec-linux-x64": "0.1.7-alpha.2", "sqlite-vec-windows-x64": "0.1.7-alpha.2" } }, "sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ=="],
|
||||
|
||||
"sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.7-alpha.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw=="],
|
||||
|
||||
"sqlite-vec-darwin-x64": ["sqlite-vec-darwin-x64@0.1.7-alpha.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA=="],
|
||||
|
||||
"sqlite-vec-linux-arm64": ["sqlite-vec-linux-arm64@0.1.7-alpha.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA=="],
|
||||
|
||||
"sqlite-vec-linux-x64": ["sqlite-vec-linux-x64@0.1.7-alpha.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg=="],
|
||||
|
||||
"sqlite-vec-windows-x64": ["sqlite-vec-windows-x64@0.1.7-alpha.2", "", { "os": "win32", "cpu": "x64" }, "sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
||||
|
||||
"stdout-update": ["stdout-update@4.0.1", "", { "dependencies": { "ansi-escapes": "^6.2.0", "ansi-styles": "^6.2.1", "string-width": "^7.1.0", "strip-ansi": "^7.1.0" } }, "sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ=="],
|
||||
|
||||
"steno": ["steno@4.0.2", "", {}, "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
|
||||
|
||||
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="],
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@6.0.2", "", {}, "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"cmake-js/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"gauge/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"gauge/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"ipull/lifecycle-utils": ["lifecycle-utils@2.1.0", "", {}, "sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA=="],
|
||||
|
||||
"ipull/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="],
|
||||
|
||||
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
||||
"proper-lockfile/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"wide-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"cmake-js/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"gauge/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"gauge/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"gauge/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ipull/pretty-ms/parse-ms": ["parse-ms@3.0.0", "", {}, "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw=="],
|
||||
|
||||
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"wide-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wide-align/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"wide-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"wide-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"name": "qmd",
|
||||
"version": "1.0.0",
|
||||
"description": "Quick Markdown Search - Full-text and vector search for markdown files",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"qmd": "./qmd"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"qmd": "bun src/qmd.ts",
|
||||
"index": "bun src/qmd.ts index",
|
||||
"vector": "bun src/qmd.ts vector",
|
||||
"search": "bun src/qmd.ts search",
|
||||
"vsearch": "bun src/qmd.ts vsearch",
|
||||
"rerank": "bun src/qmd.ts rerank",
|
||||
"link": "bun link",
|
||||
"inspector": "npx @modelcontextprotocol/inspector bun src/qmd.ts mcp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"node-llama-cpp": "^3.14.5",
|
||||
"sqlite-vec": "^0.1.7-alpha.2",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sqlite-vec-darwin-arm64": "^0.1.7-alpha.2",
|
||||
"sqlite-vec-darwin-x64": "^0.1.7-alpha.2",
|
||||
"sqlite-vec-linux-x64": "^0.1.7-alpha.2",
|
||||
"sqlite-vec-win32-x64": "^0.1.7-alpha.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"markdown",
|
||||
"search",
|
||||
"fts",
|
||||
"vector",
|
||||
"sqlite",
|
||||
"bm25",
|
||||
"embeddings",
|
||||
"ollama"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,963 +0,0 @@
|
||||
/**
|
||||
* CLI Integration Tests
|
||||
*
|
||||
* Tests all qmd CLI commands using a temporary test database via INDEX_PATH.
|
||||
* These tests spawn actual qmd processes to verify end-to-end functionality.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Test fixtures directory and database path
|
||||
let testDir: string;
|
||||
let testDbPath: string;
|
||||
let testConfigDir: string;
|
||||
let fixturesDir: string;
|
||||
let testCounter = 0; // Unique counter for each test run
|
||||
|
||||
// Get the directory where this test file lives (same as qmd.ts)
|
||||
const qmdDir = import.meta.dir;
|
||||
const qmdScript = join(qmdDir, "qmd.ts");
|
||||
|
||||
// Helper to run qmd command with test database
|
||||
async function runQmd(
|
||||
args: string[],
|
||||
options: { cwd?: string; env?: Record<string, string>; dbPath?: string; configDir?: string } = {}
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const workingDir = options.cwd || fixturesDir;
|
||||
const dbPath = options.dbPath || testDbPath;
|
||||
const configDir = options.configDir || testConfigDir;
|
||||
const proc = Bun.spawn(["bun", qmdScript, ...args], {
|
||||
cwd: workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
INDEX_PATH: dbPath,
|
||||
QMD_CONFIG_DIR: configDir, // Use test config directory
|
||||
PWD: workingDir, // Must explicitly set PWD since getPwd() checks this
|
||||
...options.env,
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
// Get a fresh database path for isolated tests
|
||||
function getFreshDbPath(): string {
|
||||
testCounter++;
|
||||
return join(testDir, `test-${testCounter}.sqlite`);
|
||||
}
|
||||
|
||||
// Create an isolated test environment (db + config dir)
|
||||
async function createIsolatedTestEnv(prefix: string): Promise<{ dbPath: string; configDir: string }> {
|
||||
testCounter++;
|
||||
const dbPath = join(testDir, `${prefix}-${testCounter}.sqlite`);
|
||||
const configDir = join(testDir, `${prefix}-config-${testCounter}`);
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await writeFile(join(configDir, "index.yml"), "collections: {}\n");
|
||||
return { dbPath, configDir };
|
||||
}
|
||||
|
||||
// Setup test fixtures
|
||||
beforeAll(async () => {
|
||||
// Create temp directory structure
|
||||
testDir = await mkdtemp(join(tmpdir(), "qmd-test-"));
|
||||
testDbPath = join(testDir, "test.sqlite");
|
||||
testConfigDir = join(testDir, "config");
|
||||
fixturesDir = join(testDir, "fixtures");
|
||||
|
||||
await mkdir(testConfigDir, { recursive: true });
|
||||
await mkdir(fixturesDir, { recursive: true });
|
||||
await mkdir(join(fixturesDir, "notes"), { recursive: true });
|
||||
await mkdir(join(fixturesDir, "docs"), { recursive: true });
|
||||
|
||||
// Create empty YAML config for tests
|
||||
await writeFile(
|
||||
join(testConfigDir, "index.yml"),
|
||||
"collections: {}\n"
|
||||
);
|
||||
|
||||
// Create test markdown files
|
||||
await writeFile(
|
||||
join(fixturesDir, "README.md"),
|
||||
`# Test Project
|
||||
|
||||
This is a test project for QMD CLI testing.
|
||||
|
||||
## Features
|
||||
|
||||
- Full-text search with BM25
|
||||
- Vector similarity search
|
||||
- Hybrid search with reranking
|
||||
`
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(fixturesDir, "notes", "meeting.md"),
|
||||
`# Team Meeting Notes
|
||||
|
||||
Date: 2024-01-15
|
||||
|
||||
## Attendees
|
||||
- Alice
|
||||
- Bob
|
||||
- Charlie
|
||||
|
||||
## Discussion Topics
|
||||
- Project timeline review
|
||||
- Resource allocation
|
||||
- Technical debt prioritization
|
||||
|
||||
## Action Items
|
||||
1. Alice to update documentation
|
||||
2. Bob to fix authentication bug
|
||||
3. Charlie to review pull requests
|
||||
`
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(fixturesDir, "notes", "ideas.md"),
|
||||
`# Product Ideas
|
||||
|
||||
## Feature Requests
|
||||
- Dark mode support
|
||||
- Keyboard shortcuts
|
||||
- Export to PDF
|
||||
|
||||
## Technical Improvements
|
||||
- Improve search performance
|
||||
- Add caching layer
|
||||
- Optimize database queries
|
||||
`
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(fixturesDir, "docs", "api.md"),
|
||||
`# API Documentation
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /search
|
||||
Search for documents.
|
||||
|
||||
Parameters:
|
||||
- q: Search query (required)
|
||||
- limit: Max results (default: 10)
|
||||
|
||||
### GET /document/:id
|
||||
Retrieve a specific document.
|
||||
|
||||
### POST /index
|
||||
Index new documents.
|
||||
`
|
||||
);
|
||||
|
||||
// Create test files for path normalization tests
|
||||
await writeFile(
|
||||
join(fixturesDir, "test1.md"),
|
||||
`# Test Document 1
|
||||
|
||||
This is the first test document.
|
||||
|
||||
It has multiple lines for testing line numbers.
|
||||
Line 6 is here.
|
||||
Line 7 is here.
|
||||
`
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(fixturesDir, "test2.md"),
|
||||
`# Test Document 2
|
||||
|
||||
This is the second test document.
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
// Cleanup after all tests
|
||||
afterAll(async () => {
|
||||
if (testDir) {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset YAML config before each test to ensure isolation
|
||||
beforeEach(async () => {
|
||||
// Reset to empty collections config
|
||||
await writeFile(
|
||||
join(testConfigDir, "index.yml"),
|
||||
"collections: {}\n"
|
||||
);
|
||||
});
|
||||
|
||||
describe("CLI Help", () => {
|
||||
test("shows help with --help flag", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["--help"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Usage:");
|
||||
expect(stdout).toContain("qmd collection add");
|
||||
expect(stdout).toContain("qmd search");
|
||||
});
|
||||
|
||||
test("shows help with no arguments", async () => {
|
||||
const { stdout, exitCode } = await runQmd([]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Add Command", () => {
|
||||
test("adds files from current directory", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["collection", "add", "."]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Collection:");
|
||||
expect(stdout).toContain("Indexed:");
|
||||
});
|
||||
|
||||
test("adds files with custom glob pattern", async () => {
|
||||
const { stdout, stderr, exitCode } = await runQmd(["collection", "add", ".", "--mask", "notes/*.md"]);
|
||||
if (exitCode !== 0) {
|
||||
console.error("Command failed:", stderr);
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Collection:");
|
||||
// Should find meeting.md and ideas.md in notes/
|
||||
expect(stdout).toContain("notes/*.md");
|
||||
});
|
||||
|
||||
test("can recreate collection with remove and add", async () => {
|
||||
// First add
|
||||
await runQmd(["collection", "add", "."]);
|
||||
// Remove it
|
||||
await runQmd(["collection", "remove", "fixtures"]);
|
||||
// Re-add
|
||||
const { stdout, exitCode } = await runQmd(["collection", "add", "."]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Collection 'fixtures' created successfully");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Status Command", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure we have indexed files
|
||||
await runQmd(["collection", "add", "."]);
|
||||
});
|
||||
|
||||
test("shows index status", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["status"]);
|
||||
expect(exitCode).toBe(0);
|
||||
// Should show collection info
|
||||
expect(stdout).toContain("Collection");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Search Command", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure we have indexed files
|
||||
await runQmd(["collection", "add", "."]);
|
||||
});
|
||||
|
||||
test("searches for documents with BM25", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "meeting"]);
|
||||
expect(exitCode).toBe(0);
|
||||
// Should find meeting.md
|
||||
expect(stdout.toLowerCase()).toContain("meeting");
|
||||
});
|
||||
|
||||
test("searches with limit option", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "-n", "1", "test"]);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("searches with all results option", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "--all", "the"]);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("returns no results message for non-matching query", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("No results");
|
||||
});
|
||||
|
||||
test("requires query argument", async () => {
|
||||
const { stdout, stderr, exitCode } = await runQmd(["search"]);
|
||||
expect(exitCode).toBe(1);
|
||||
// Error message goes to stderr
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Get Command", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure we have indexed files
|
||||
await runQmd(["collection", "add", "."]);
|
||||
});
|
||||
|
||||
test("retrieves document content by path", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", "README.md"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Project");
|
||||
});
|
||||
|
||||
test("retrieves document from subdirectory", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", "notes/meeting.md"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Team Meeting");
|
||||
});
|
||||
|
||||
test("handles non-existent file", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", "nonexistent.md"]);
|
||||
// Should indicate file not found
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Multi-Get Command", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use fresh database for each test
|
||||
localDbPath = getFreshDbPath();
|
||||
// Ensure we have indexed files
|
||||
const addResult = await runQmd(["collection", "add", ".", "--name", "fixtures"], { dbPath: localDbPath });
|
||||
if (addResult.exitCode !== 0) {
|
||||
throw new Error(`Failed to add collection: ${addResult.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves multiple documents by pattern", async () => {
|
||||
// Test glob pattern matching
|
||||
const { stdout, stderr, exitCode } = await runQmd(["multi-get", "notes/*.md"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
// Should contain content from both notes files
|
||||
expect(stdout).toContain("Meeting");
|
||||
expect(stdout).toContain("Ideas");
|
||||
});
|
||||
|
||||
test("retrieves documents by comma-separated paths", async () => {
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"multi-get",
|
||||
"README.md,notes/meeting.md",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Project");
|
||||
expect(stdout).toContain("Team Meeting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Update Command", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use a fresh database for this test suite
|
||||
localDbPath = getFreshDbPath();
|
||||
// Ensure we have indexed files
|
||||
await runQmd(["collection", "add", "."], { dbPath: localDbPath });
|
||||
});
|
||||
|
||||
test("updates all collections", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["update"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Updating");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Add-Context Command", () => {
|
||||
let localDbPath: string;
|
||||
let localConfigDir: string;
|
||||
const collName = "fixtures";
|
||||
|
||||
beforeAll(async () => {
|
||||
const env = await createIsolatedTestEnv("context-cmd");
|
||||
localDbPath = env.dbPath;
|
||||
localConfigDir = env.configDir;
|
||||
|
||||
// Add collection with known name
|
||||
const { exitCode, stderr } = await runQmd(
|
||||
["collection", "add", fixturesDir, "--name", collName],
|
||||
{ dbPath: localDbPath, configDir: localConfigDir }
|
||||
);
|
||||
if (exitCode !== 0) console.error("collection add failed:", stderr);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("adds context to a path", async () => {
|
||||
// Add context to the collection root using virtual path
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"context",
|
||||
"add",
|
||||
`qmd://${collName}/`,
|
||||
"Personal notes and meeting logs",
|
||||
], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Added context");
|
||||
});
|
||||
|
||||
test("requires path and text arguments", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["context", "add"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(1);
|
||||
// Error message goes to stderr
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Cleanup Command", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure we have indexed files
|
||||
await runQmd(["collection", "add", "."]);
|
||||
});
|
||||
|
||||
test("cleans up orphaned entries", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["cleanup"]);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Error Handling", () => {
|
||||
test("handles unknown command", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["unknowncommand"]);
|
||||
expect(exitCode).toBe(1);
|
||||
// Should indicate unknown command
|
||||
expect(stderr).toContain("Unknown command");
|
||||
});
|
||||
|
||||
test("uses INDEX_PATH environment variable", async () => {
|
||||
// Verify the test DB path is being used by creating a separate index
|
||||
const customDbPath = join(testDir, "custom.sqlite");
|
||||
const { exitCode } = await runQmd(["collection", "add", "."], {
|
||||
env: { INDEX_PATH: customDbPath },
|
||||
});
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// The custom database should exist
|
||||
const file = Bun.file(customDbPath);
|
||||
expect(await file.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Output Formats", () => {
|
||||
beforeEach(async () => {
|
||||
await runQmd(["collection", "add", "."]);
|
||||
});
|
||||
|
||||
test("search with --json flag outputs JSON", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "--json", "test"]);
|
||||
expect(exitCode).toBe(0);
|
||||
// Should be valid JSON
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
|
||||
test("search with --files flag outputs file paths", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "--files", "meeting"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(".md");
|
||||
});
|
||||
|
||||
test("search output includes snippets by default", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "API"]);
|
||||
expect(exitCode).toBe(0);
|
||||
// If results found, should have snippet content
|
||||
if (!stdout.includes("No results")) {
|
||||
expect(stdout.toLowerCase()).toContain("api");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Search with Collection Filter", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use a fresh database for this test suite
|
||||
localDbPath = getFreshDbPath();
|
||||
// Create multiple collections with explicit names
|
||||
await runQmd(["collection", "add", ".", "--name", "notes", "--mask", "notes/*.md"], { dbPath: localDbPath });
|
||||
await runQmd(["collection", "add", ".", "--name", "docs", "--mask", "docs/*.md"], { dbPath: localDbPath });
|
||||
});
|
||||
|
||||
test("filters search by collection name", async () => {
|
||||
const { stdout, stderr, exitCode } = await runQmd([
|
||||
"search",
|
||||
"-c",
|
||||
"notes",
|
||||
"meeting",
|
||||
], { dbPath: localDbPath });
|
||||
if (exitCode !== 0) {
|
||||
console.log("Collection filter search failed:");
|
||||
console.log("stdout:", stdout);
|
||||
console.log("stderr:", stderr);
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Context Management", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use a fresh database for this test suite
|
||||
localDbPath = getFreshDbPath();
|
||||
// Index some files first
|
||||
await runQmd(["collection", "add", "."], { dbPath: localDbPath });
|
||||
});
|
||||
|
||||
test("add global context with /", async () => {
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"context",
|
||||
"add",
|
||||
"/",
|
||||
"Global system context",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Set global context");
|
||||
expect(stdout).toContain("Global system context");
|
||||
});
|
||||
|
||||
test("list contexts", async () => {
|
||||
// Add a global context first
|
||||
await runQmd([
|
||||
"context",
|
||||
"add",
|
||||
"/",
|
||||
"Test context",
|
||||
], { dbPath: localDbPath });
|
||||
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"context",
|
||||
"list",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Configured Contexts");
|
||||
expect(stdout).toContain("Test context");
|
||||
});
|
||||
|
||||
test("add context to virtual path", async () => {
|
||||
// Collection name should be "fixtures" (basename of the fixtures directory)
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"context",
|
||||
"add",
|
||||
"qmd://fixtures/notes",
|
||||
"Context for notes subdirectory",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Added context for: qmd://fixtures/notes");
|
||||
});
|
||||
|
||||
test("remove global context", async () => {
|
||||
// Add a global context first
|
||||
await runQmd([
|
||||
"context",
|
||||
"add",
|
||||
"/",
|
||||
"Global context to remove",
|
||||
], { dbPath: localDbPath });
|
||||
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"context",
|
||||
"rm",
|
||||
"/",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Removed");
|
||||
});
|
||||
|
||||
test("remove virtual path context", async () => {
|
||||
// Add a context first
|
||||
await runQmd([
|
||||
"context",
|
||||
"add",
|
||||
"qmd://fixtures/notes",
|
||||
"Context to remove",
|
||||
], { dbPath: localDbPath });
|
||||
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"context",
|
||||
"rm",
|
||||
"qmd://fixtures/notes",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Removed context for: qmd://fixtures/notes");
|
||||
});
|
||||
|
||||
test("fails to remove non-existent context", async () => {
|
||||
const { stdout, stderr, exitCode } = await runQmd([
|
||||
"context",
|
||||
"rm",
|
||||
"qmd://nonexistent/path",
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr || stdout).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI ls Command", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use a fresh database for this test suite
|
||||
localDbPath = getFreshDbPath();
|
||||
// Index some files first
|
||||
await runQmd(["collection", "add", "."], { dbPath: localDbPath });
|
||||
});
|
||||
|
||||
test("lists all collections", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["ls"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Collections:");
|
||||
expect(stdout).toContain("qmd://fixtures/");
|
||||
});
|
||||
|
||||
test("lists files in a collection", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["ls", "fixtures"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
// handelize converts to lowercase
|
||||
expect(stdout).toContain("qmd://fixtures/readme.md");
|
||||
expect(stdout).toContain("qmd://fixtures/notes/meeting.md");
|
||||
});
|
||||
|
||||
test("lists files with path prefix", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["ls", "fixtures/notes"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("qmd://fixtures/notes/meeting.md");
|
||||
expect(stdout).toContain("qmd://fixtures/notes/ideas.md");
|
||||
// Should not include files outside the prefix (handelize converts to lowercase)
|
||||
expect(stdout).not.toContain("qmd://fixtures/readme.md");
|
||||
});
|
||||
|
||||
test("lists files with virtual path", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["ls", "qmd://fixtures/docs"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("qmd://fixtures/docs/api.md");
|
||||
});
|
||||
|
||||
test("handles non-existent collection", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["ls", "nonexistent"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Collection not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Collection Commands", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use a fresh database for this test suite
|
||||
localDbPath = getFreshDbPath();
|
||||
// Index some files first to create a collection
|
||||
await runQmd(["collection", "add", "."], { dbPath: localDbPath });
|
||||
});
|
||||
|
||||
test("lists collections", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["collection", "list"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Collections");
|
||||
expect(stdout).toContain("fixtures");
|
||||
expect(stdout).toContain("qmd://fixtures/");
|
||||
expect(stdout).toContain("Pattern:");
|
||||
expect(stdout).toContain("Files:");
|
||||
});
|
||||
|
||||
test("removes a collection", async () => {
|
||||
// First verify the collection exists
|
||||
const { stdout: listBefore } = await runQmd(["collection", "list"], { dbPath: localDbPath });
|
||||
expect(listBefore).toContain("fixtures");
|
||||
|
||||
// Remove it
|
||||
const { stdout, exitCode } = await runQmd(["collection", "remove", "fixtures"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Removed collection 'fixtures'");
|
||||
expect(stdout).toContain("Deleted");
|
||||
|
||||
// Verify it's gone
|
||||
const { stdout: listAfter } = await runQmd(["collection", "list"], { dbPath: localDbPath });
|
||||
expect(listAfter).not.toContain("fixtures");
|
||||
});
|
||||
|
||||
test("handles removing non-existent collection", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["collection", "remove", "nonexistent"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Collection not found");
|
||||
});
|
||||
|
||||
test("handles missing remove argument", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["collection", "remove"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("handles unknown subcommand", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["collection", "invalid"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Unknown subcommand");
|
||||
});
|
||||
|
||||
test("renames a collection", async () => {
|
||||
// First verify the collection exists
|
||||
const { stdout: listBefore } = await runQmd(["collection", "list"], { dbPath: localDbPath });
|
||||
expect(listBefore).toContain("qmd://fixtures/");
|
||||
|
||||
// Rename it
|
||||
const { stdout, exitCode } = await runQmd(["collection", "rename", "fixtures", "my-fixtures"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("✓ Renamed collection 'fixtures' to 'my-fixtures'");
|
||||
expect(stdout).toContain("qmd://fixtures/");
|
||||
expect(stdout).toContain("qmd://my-fixtures/");
|
||||
|
||||
// Verify the new name exists and old name is gone
|
||||
const { stdout: listAfter } = await runQmd(["collection", "list"], { dbPath: localDbPath });
|
||||
expect(listAfter).toContain("qmd://my-fixtures/");
|
||||
expect(listAfter).not.toContain("qmd://fixtures/"); // Old collection should not appear
|
||||
});
|
||||
|
||||
test("handles renaming non-existent collection", async () => {
|
||||
const { stderr, exitCode } = await runQmd(["collection", "rename", "nonexistent", "newname"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Collection not found");
|
||||
});
|
||||
|
||||
test("handles renaming to existing collection name", async () => {
|
||||
// Create a second collection in a temp directory
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "qmd-second-"));
|
||||
await writeFile(join(tempDir, "test.md"), "# Test");
|
||||
const addResult = await runQmd(["collection", "add", tempDir, "--name", "second"], { dbPath: localDbPath });
|
||||
|
||||
if (addResult.exitCode !== 0) {
|
||||
console.error("Failed to add second collection:", addResult.stderr);
|
||||
}
|
||||
expect(addResult.exitCode).toBe(0);
|
||||
|
||||
// Verify both collections exist
|
||||
const { stdout: listBoth } = await runQmd(["collection", "list"], { dbPath: localDbPath });
|
||||
expect(listBoth).toContain("qmd://fixtures/");
|
||||
expect(listBoth).toContain("qmd://second/");
|
||||
|
||||
// Try to rename fixtures to second (which already exists)
|
||||
const { stderr, exitCode } = await runQmd(["collection", "rename", "fixtures", "second"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Collection name already exists");
|
||||
});
|
||||
|
||||
test("handles missing rename arguments", async () => {
|
||||
const { stderr: stderr1, exitCode: exitCode1 } = await runQmd(["collection", "rename"], { dbPath: localDbPath });
|
||||
expect(exitCode1).toBe(1);
|
||||
expect(stderr1).toContain("Usage:");
|
||||
|
||||
const { stderr: stderr2, exitCode: exitCode2 } = await runQmd(["collection", "rename", "fixtures"], { dbPath: localDbPath });
|
||||
expect(exitCode2).toBe(1);
|
||||
expect(stderr2).toContain("Usage:");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Output Format Tests - qmd:// URIs, context, and docid
|
||||
// =============================================================================
|
||||
|
||||
describe("search output formats", () => {
|
||||
let localDbPath: string;
|
||||
let localConfigDir: string;
|
||||
const collName = "fixtures";
|
||||
|
||||
beforeAll(async () => {
|
||||
const env = await createIsolatedTestEnv("output-format");
|
||||
localDbPath = env.dbPath;
|
||||
localConfigDir = env.configDir;
|
||||
|
||||
// Add collection
|
||||
const { exitCode, stderr } = await runQmd(
|
||||
["collection", "add", fixturesDir, "--name", collName],
|
||||
{ dbPath: localDbPath, configDir: localConfigDir }
|
||||
);
|
||||
if (exitCode !== 0) console.error("collection add failed:", stderr);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Add context
|
||||
await runQmd(["context", "add", `qmd://${collName}/`, "Test fixtures for QMD"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
});
|
||||
|
||||
test("search --json includes qmd:// path, docid, and context", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "test", "--json", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const results = JSON.parse(stdout);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
const result = results[0];
|
||||
expect(result.file).toMatch(new RegExp(`^qmd://${collName}/`));
|
||||
expect(result.docid).toMatch(/^#[a-f0-9]{6}$/);
|
||||
expect(result.context).toBe("Test fixtures for QMD");
|
||||
// Ensure no full filesystem paths
|
||||
expect(result.file).not.toMatch(/^\/Users\//);
|
||||
expect(result.file).not.toMatch(/^\/home\//);
|
||||
});
|
||||
|
||||
test("search --files includes qmd:// path, docid, and context", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "test", "--files", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Format: #docid,score,qmd://collection/path,"context"
|
||||
expect(stdout).toMatch(new RegExp(`^#[a-f0-9]{6},[\\d.]+,qmd://${collName}/`, "m"));
|
||||
expect(stdout).toContain("Test fixtures for QMD");
|
||||
// Ensure no full filesystem paths
|
||||
expect(stdout).not.toMatch(/\/Users\//);
|
||||
expect(stdout).not.toMatch(/\/home\//);
|
||||
});
|
||||
|
||||
test("search --csv includes qmd:// path, docid, and context", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "test", "--csv", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Header should include context
|
||||
expect(stdout).toMatch(/^docid,score,file,title,context,line,snippet$/m);
|
||||
// Data rows should have qmd:// paths and context
|
||||
expect(stdout).toMatch(new RegExp(`#[a-f0-9]{6},[\\d.]+,qmd://${collName}/`));
|
||||
expect(stdout).toContain("Test fixtures for QMD");
|
||||
// Ensure no full filesystem paths
|
||||
expect(stdout).not.toMatch(/\/Users\//);
|
||||
expect(stdout).not.toMatch(/\/home\//);
|
||||
});
|
||||
|
||||
test("search --md includes docid and context", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "test", "--md", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(stdout).toMatch(/\*\*docid:\*\* `#[a-f0-9]{6}`/);
|
||||
expect(stdout).toContain("**context:** Test fixtures for QMD");
|
||||
});
|
||||
|
||||
test("search --xml includes qmd:// path, docid, and context", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "test", "--xml", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(stdout).toMatch(new RegExp(`<file docid="#[a-f0-9]{6}" name="qmd://${collName}/`));
|
||||
expect(stdout).toContain('context="Test fixtures for QMD"');
|
||||
// Ensure no full filesystem paths
|
||||
expect(stdout).not.toMatch(/\/Users\//);
|
||||
expect(stdout).not.toMatch(/\/home\//);
|
||||
});
|
||||
|
||||
test("search default CLI format includes qmd:// path, docid, and context", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["search", "test", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// First line should have qmd:// path and docid
|
||||
expect(stdout).toMatch(new RegExp(`^qmd://${collName}/.*#[a-f0-9]{6}`, "m"));
|
||||
expect(stdout).toContain("Context: Test fixtures for QMD");
|
||||
// Ensure no full filesystem paths
|
||||
expect(stdout).not.toMatch(/\/Users\//);
|
||||
expect(stdout).not.toMatch(/\/home\//);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Get Command Path Normalization Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("get command path normalization", () => {
|
||||
let localDbPath: string;
|
||||
let localConfigDir: string;
|
||||
const collName = "fixtures";
|
||||
|
||||
beforeAll(async () => {
|
||||
const env = await createIsolatedTestEnv("get-paths");
|
||||
localDbPath = env.dbPath;
|
||||
localConfigDir = env.configDir;
|
||||
|
||||
const { exitCode, stderr } = await runQmd(
|
||||
["collection", "add", fixturesDir, "--name", collName],
|
||||
{ dbPath: localDbPath, configDir: localConfigDir }
|
||||
);
|
||||
if (exitCode !== 0) console.error("collection add failed:", stderr);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("get with qmd://collection/path format", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Document 1");
|
||||
});
|
||||
|
||||
test("get with collection/path format (no scheme)", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Document 1");
|
||||
});
|
||||
|
||||
test("get with //collection/path format", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", `//${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Document 1");
|
||||
});
|
||||
|
||||
test("get with qmd:////collection/path format (extra slashes)", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", `qmd:////${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Document 1");
|
||||
});
|
||||
|
||||
test("get with path:line format", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:3`, "-l", "2"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
// Should start from line 3, not line 1
|
||||
expect(stdout).not.toMatch(/^# Test Document 1$/m);
|
||||
});
|
||||
|
||||
test("get with qmd://path:line format", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md:3`, "-l", "2"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
// Should start from line 3, not line 1
|
||||
expect(stdout).not.toMatch(/^# Test Document 1$/m);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Status and Collection List - No Full Paths
|
||||
// =============================================================================
|
||||
|
||||
describe("status and collection list hide filesystem paths", () => {
|
||||
let localDbPath: string;
|
||||
let localConfigDir: string;
|
||||
const collName = "fixtures";
|
||||
|
||||
beforeAll(async () => {
|
||||
const env = await createIsolatedTestEnv("status-paths");
|
||||
localDbPath = env.dbPath;
|
||||
localConfigDir = env.configDir;
|
||||
|
||||
const { exitCode, stderr } = await runQmd(
|
||||
["collection", "add", fixturesDir, "--name", collName],
|
||||
{ dbPath: localDbPath, configDir: localConfigDir }
|
||||
);
|
||||
if (exitCode !== 0) console.error("collection add failed:", stderr);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("status does not show full filesystem paths", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["status"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Should show qmd:// URIs
|
||||
expect(stdout).toContain(`qmd://${collName}/`);
|
||||
// Should NOT show full filesystem paths (except for the index location which is ok)
|
||||
const lines = stdout.split('\n').filter(l => !l.includes('Index:'));
|
||||
const pathLines = lines.filter(l => l.includes('/Users/') || l.includes('/home/') || l.includes('/tmp/'));
|
||||
expect(pathLines.length).toBe(0);
|
||||
});
|
||||
|
||||
test("collection list does not show full filesystem paths", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["collection", "list"], { dbPath: localDbPath, configDir: localConfigDir });
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Should show qmd:// URIs
|
||||
expect(stdout).toContain(`qmd://${collName}/`);
|
||||
// Should NOT show Path: lines with filesystem paths
|
||||
expect(stdout).not.toMatch(/Path:\s+\//);
|
||||
});
|
||||
});
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* Collections configuration management
|
||||
*
|
||||
* This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
|
||||
* Collections define which directories to index and their associated contexts.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import YAML from "yaml";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Context definitions for a collection
|
||||
* Key is path prefix (e.g., "/", "/2024", "/Board of Directors")
|
||||
* Value is the context description
|
||||
*/
|
||||
export type ContextMap = Record<string, string>;
|
||||
|
||||
/**
|
||||
* A single collection configuration
|
||||
*/
|
||||
export interface Collection {
|
||||
path: string; // Absolute path to index
|
||||
pattern: string; // Glob pattern (e.g., "**/*.md")
|
||||
context?: ContextMap; // Optional context definitions
|
||||
update?: string; // Optional bash command to run during qmd update
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete configuration file structure
|
||||
*/
|
||||
export interface CollectionConfig {
|
||||
global_context?: string; // Context applied to all collections
|
||||
collections: Record<string, Collection>; // Collection name -> config
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection with its name (for return values)
|
||||
*/
|
||||
export interface NamedCollection extends Collection {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration paths
|
||||
// ============================================================================
|
||||
|
||||
// Current index name (default: "index")
|
||||
let currentIndexName: string = "index";
|
||||
|
||||
/**
|
||||
* Set the current index name for config file lookup
|
||||
* Config file will be ~/.config/qmd/{indexName}.yml
|
||||
*/
|
||||
export function setConfigIndexName(name: string): void {
|
||||
currentIndexName = name;
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
// Allow override via QMD_CONFIG_DIR for testing
|
||||
if (process.env.QMD_CONFIG_DIR) {
|
||||
return process.env.QMD_CONFIG_DIR;
|
||||
}
|
||||
return join(homedir(), ".config", "qmd");
|
||||
}
|
||||
|
||||
function getConfigFilePath(): string {
|
||||
return join(getConfigDir(), `${currentIndexName}.yml`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure config directory exists
|
||||
*/
|
||||
function ensureConfigDir(): void {
|
||||
const configDir = getConfigDir();
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load configuration from ~/.config/qmd/index.yml
|
||||
* Returns empty config if file doesn't exist
|
||||
*/
|
||||
export function loadConfig(): CollectionConfig {
|
||||
const configPath = getConfigFilePath();
|
||||
if (!existsSync(configPath)) {
|
||||
return { collections: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8");
|
||||
const config = YAML.parse(content) as CollectionConfig;
|
||||
|
||||
// Ensure collections object exists
|
||||
if (!config.collections) {
|
||||
config.collections = {};
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${configPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration to ~/.config/qmd/index.yml
|
||||
*/
|
||||
export function saveConfig(config: CollectionConfig): void {
|
||||
ensureConfigDir();
|
||||
const configPath = getConfigFilePath();
|
||||
|
||||
try {
|
||||
const yaml = YAML.stringify(config, {
|
||||
indent: 2,
|
||||
lineWidth: 0, // Don't wrap lines
|
||||
});
|
||||
writeFileSync(configPath, yaml, "utf-8");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to write ${configPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific collection by name
|
||||
* Returns null if not found
|
||||
*/
|
||||
export function getCollection(name: string): NamedCollection | null {
|
||||
const config = loadConfig();
|
||||
const collection = config.collections[name];
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, ...collection };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all collections
|
||||
*/
|
||||
export function listCollections(): NamedCollection[] {
|
||||
const config = loadConfig();
|
||||
return Object.entries(config.collections).map(([name, collection]) => ({
|
||||
name,
|
||||
...collection,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a collection
|
||||
*/
|
||||
export function addCollection(
|
||||
name: string,
|
||||
path: string,
|
||||
pattern: string = "**/*.md"
|
||||
): void {
|
||||
const config = loadConfig();
|
||||
|
||||
config.collections[name] = {
|
||||
path,
|
||||
pattern,
|
||||
context: config.collections[name]?.context, // Preserve existing context
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a collection
|
||||
*/
|
||||
export function removeCollection(name: string): boolean {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.collections[name]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete config.collections[name];
|
||||
saveConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a collection
|
||||
*/
|
||||
export function renameCollection(oldName: string, newName: string): boolean {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.collections[oldName]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.collections[newName]) {
|
||||
throw new Error(`Collection '${newName}' already exists`);
|
||||
}
|
||||
|
||||
config.collections[newName] = config.collections[oldName];
|
||||
delete config.collections[oldName];
|
||||
saveConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get global context
|
||||
*/
|
||||
export function getGlobalContext(): string | undefined {
|
||||
const config = loadConfig();
|
||||
return config.global_context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global context
|
||||
*/
|
||||
export function setGlobalContext(context: string | undefined): void {
|
||||
const config = loadConfig();
|
||||
config.global_context = context;
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contexts for a collection
|
||||
*/
|
||||
export function getContexts(collectionName: string): ContextMap | undefined {
|
||||
const collection = getCollection(collectionName);
|
||||
return collection?.context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a context for a specific path in a collection
|
||||
*/
|
||||
export function addContext(
|
||||
collectionName: string,
|
||||
pathPrefix: string,
|
||||
contextText: string
|
||||
): boolean {
|
||||
const config = loadConfig();
|
||||
const collection = config.collections[collectionName];
|
||||
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!collection.context) {
|
||||
collection.context = {};
|
||||
}
|
||||
|
||||
collection.context[pathPrefix] = contextText;
|
||||
saveConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a context from a collection
|
||||
*/
|
||||
export function removeContext(
|
||||
collectionName: string,
|
||||
pathPrefix: string
|
||||
): boolean {
|
||||
const config = loadConfig();
|
||||
const collection = config.collections[collectionName];
|
||||
|
||||
if (!collection?.context?.[pathPrefix]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete collection.context[pathPrefix];
|
||||
|
||||
// Remove empty context object
|
||||
if (Object.keys(collection.context).length === 0) {
|
||||
delete collection.context;
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all contexts across all collections
|
||||
*/
|
||||
export function listAllContexts(): Array<{
|
||||
collection: string;
|
||||
path: string;
|
||||
context: string;
|
||||
}> {
|
||||
const config = loadConfig();
|
||||
const results: Array<{ collection: string; path: string; context: string }> = [];
|
||||
|
||||
// Add global context if present
|
||||
if (config.global_context) {
|
||||
results.push({
|
||||
collection: "*",
|
||||
path: "/",
|
||||
context: config.global_context,
|
||||
});
|
||||
}
|
||||
|
||||
// Add collection contexts
|
||||
for (const [name, collection] of Object.entries(config.collections)) {
|
||||
if (collection.context) {
|
||||
for (const [path, context] of Object.entries(collection.context)) {
|
||||
results.push({
|
||||
collection: name,
|
||||
path,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find best matching context for a given collection and path
|
||||
* Returns the most specific matching context (longest path prefix match)
|
||||
*/
|
||||
export function findContextForPath(
|
||||
collectionName: string,
|
||||
filePath: string
|
||||
): string | undefined {
|
||||
const config = loadConfig();
|
||||
const collection = config.collections[collectionName];
|
||||
|
||||
if (!collection?.context) {
|
||||
return config.global_context;
|
||||
}
|
||||
|
||||
// Find all matching prefixes
|
||||
const matches: Array<{ prefix: string; context: string }> = [];
|
||||
|
||||
for (const [prefix, context] of Object.entries(collection.context)) {
|
||||
// Normalize paths for comparison
|
||||
const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
|
||||
const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
|
||||
|
||||
if (normalizedPath.startsWith(normalizedPrefix)) {
|
||||
matches.push({ prefix: normalizedPrefix, context });
|
||||
}
|
||||
}
|
||||
|
||||
// Return most specific match (longest prefix)
|
||||
if (matches.length > 0) {
|
||||
matches.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
return matches[0]!.context;
|
||||
}
|
||||
|
||||
// Fallback to global context
|
||||
return config.global_context;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the config file path (useful for error messages)
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return getConfigFilePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config file exists
|
||||
*/
|
||||
export function configExists(): boolean {
|
||||
return existsSync(getConfigFilePath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a collection name
|
||||
* Collection names must be valid and not contain special characters
|
||||
*/
|
||||
export function isValidCollectionName(name: string): boolean {
|
||||
// Allow alphanumeric, hyphens, underscores
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* Evaluation Tests for QMD Search Quality
|
||||
*
|
||||
* Tests search quality against synthetic documents with known-answer queries.
|
||||
* Validates that search improvements don't regress quality.
|
||||
*
|
||||
* Three test suites:
|
||||
* 1. BM25 (FTS) - lexical search baseline
|
||||
* 2. Vector Search - semantic search with embeddings
|
||||
* 3. Hybrid (RRF) - combined lexical + vector with rank fusion
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import Database from "bun:sqlite";
|
||||
|
||||
// Set INDEX_PATH before importing store to prevent using global index
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "qmd-eval-"));
|
||||
process.env.INDEX_PATH = join(tempDir, "eval.sqlite");
|
||||
|
||||
import {
|
||||
createStore,
|
||||
searchFTS,
|
||||
searchVec,
|
||||
insertDocument,
|
||||
insertContent,
|
||||
insertEmbedding,
|
||||
chunkDocumentByTokens,
|
||||
reciprocalRankFusion,
|
||||
DEFAULT_EMBED_MODEL,
|
||||
type RankedResult,
|
||||
} from "./store";
|
||||
import { getDefaultLlamaCpp, formatDocForEmbedding, disposeDefaultLlamaCpp } from "./llm";
|
||||
|
||||
// Eval queries with expected documents
|
||||
const evalQueries: {
|
||||
query: string;
|
||||
expectedDoc: string;
|
||||
difficulty: "easy" | "medium" | "hard" | "fusion";
|
||||
}[] = [
|
||||
// EASY: Exact keyword matches
|
||||
{ query: "API versioning", expectedDoc: "api-design", difficulty: "easy" },
|
||||
{ query: "Series A fundraising", expectedDoc: "fundraising", difficulty: "easy" },
|
||||
{ query: "CAP theorem", expectedDoc: "distributed-systems", difficulty: "easy" },
|
||||
{ query: "overfitting machine learning", expectedDoc: "machine-learning", difficulty: "easy" },
|
||||
{ query: "remote work VPN", expectedDoc: "remote-work", difficulty: "easy" },
|
||||
{ query: "Project Phoenix retrospective", expectedDoc: "product-launch", difficulty: "easy" },
|
||||
|
||||
// MEDIUM: Semantic/conceptual queries
|
||||
{ query: "how to structure REST endpoints", expectedDoc: "api-design", difficulty: "medium" },
|
||||
{ query: "raising money for startup", expectedDoc: "fundraising", difficulty: "medium" },
|
||||
{ query: "consistency vs availability tradeoffs", expectedDoc: "distributed-systems", difficulty: "medium" },
|
||||
{ query: "how to prevent models from memorizing data", expectedDoc: "machine-learning", difficulty: "medium" },
|
||||
{ query: "working from home guidelines", expectedDoc: "remote-work", difficulty: "medium" },
|
||||
{ query: "what went wrong with the launch", expectedDoc: "product-launch", difficulty: "medium" },
|
||||
|
||||
// HARD: Vague, partial memory, indirect
|
||||
{ query: "nouns not verbs", expectedDoc: "api-design", difficulty: "hard" },
|
||||
{ query: "Sequoia investor pitch", expectedDoc: "fundraising", difficulty: "hard" },
|
||||
{ query: "Raft algorithm leader election", expectedDoc: "distributed-systems", difficulty: "hard" },
|
||||
{ query: "F1 score precision recall", expectedDoc: "machine-learning", difficulty: "hard" },
|
||||
{ query: "quarterly team gathering travel", expectedDoc: "remote-work", difficulty: "hard" },
|
||||
{ query: "beta program 47 bugs", expectedDoc: "product-launch", difficulty: "hard" },
|
||||
|
||||
// FUSION: Multi-signal queries that need both lexical AND semantic matching
|
||||
// These should have weak individual scores but strong combined RRF scores
|
||||
{ query: "how much runway before running out of money", expectedDoc: "fundraising", difficulty: "fusion" },
|
||||
{ query: "datacenter replication sync strategy", expectedDoc: "distributed-systems", difficulty: "fusion" },
|
||||
{ query: "splitting data for training and testing", expectedDoc: "machine-learning", difficulty: "fusion" },
|
||||
{ query: "JSON response codes error messages", expectedDoc: "api-design", difficulty: "fusion" },
|
||||
{ query: "video calls camera async messaging", expectedDoc: "remote-work", difficulty: "fusion" },
|
||||
{ query: "CI/CD pipeline testing coverage", expectedDoc: "product-launch", difficulty: "fusion" },
|
||||
];
|
||||
|
||||
// Helper to check if result matches expected doc
|
||||
function matchesExpected(filepath: string, expectedDoc: string): boolean {
|
||||
return filepath.toLowerCase().includes(expectedDoc);
|
||||
}
|
||||
|
||||
// Helper to calculate hit rate
|
||||
function calcHitRate(
|
||||
queries: typeof evalQueries,
|
||||
searchFn: (query: string) => { filepath: string }[],
|
||||
topK: number
|
||||
): number {
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of queries) {
|
||||
const results = searchFn(query).slice(0, topK);
|
||||
if (results.some(r => matchesExpected(r.filepath, expectedDoc))) hits++;
|
||||
}
|
||||
return hits / queries.length;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BM25 (Lexical) Tests - Fast, no model loading needed
|
||||
// =============================================================================
|
||||
|
||||
describe("BM25 Search (FTS)", () => {
|
||||
let store: ReturnType<typeof createStore>;
|
||||
let db: Database;
|
||||
|
||||
beforeAll(() => {
|
||||
store = createStore();
|
||||
db = store.db;
|
||||
|
||||
// Load and index eval documents
|
||||
const evalDocsDir = join(import.meta.dir, "../test/eval-docs");
|
||||
const files = readdirSync(evalDocsDir).filter(f => f.endsWith(".md"));
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(evalDocsDir, file), "utf-8");
|
||||
const title = content.split("\n")[0]?.replace(/^#\s*/, "") || file;
|
||||
const hash = Bun.hash(content).toString(16).slice(0, 12);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
insertContent(db, hash, content, now);
|
||||
insertDocument(db, "eval-docs", file, title, hash, now, now);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
test("easy queries: ≥80% Hit@3", () => {
|
||||
const easyQueries = evalQueries.filter(q => q.difficulty === "easy");
|
||||
const hitRate = calcHitRate(easyQueries, q => searchFTS(db, q, 5), 3);
|
||||
expect(hitRate).toBeGreaterThanOrEqual(0.8);
|
||||
});
|
||||
|
||||
test("medium queries: ≥15% Hit@3 (BM25 struggles with semantic)", () => {
|
||||
const mediumQueries = evalQueries.filter(q => q.difficulty === "medium");
|
||||
const hitRate = calcHitRate(mediumQueries, q => searchFTS(db, q, 5), 3);
|
||||
expect(hitRate).toBeGreaterThanOrEqual(0.15);
|
||||
});
|
||||
|
||||
test("hard queries: ≥15% Hit@5 (BM25 baseline)", () => {
|
||||
const hardQueries = evalQueries.filter(q => q.difficulty === "hard");
|
||||
const hitRate = calcHitRate(hardQueries, q => searchFTS(db, q, 5), 5);
|
||||
expect(hitRate).toBeGreaterThanOrEqual(0.15);
|
||||
});
|
||||
|
||||
test("overall Hit@3 ≥40% (BM25 baseline)", () => {
|
||||
const hitRate = calcHitRate(evalQueries, q => searchFTS(db, q, 5), 3);
|
||||
expect(hitRate).toBeGreaterThanOrEqual(0.4);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Vector Search Tests - Requires embedding model
|
||||
// =============================================================================
|
||||
|
||||
describe("Vector Search", () => {
|
||||
let store: ReturnType<typeof createStore>;
|
||||
let db: Database;
|
||||
let hasEmbeddings = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = createStore();
|
||||
db = store.db;
|
||||
|
||||
// Check if embeddings already exist (from previous test run)
|
||||
const vecTable = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`
|
||||
).get();
|
||||
|
||||
if (vecTable) {
|
||||
const count = db.prepare(`SELECT COUNT(*) as cnt FROM vectors_vec`).get() as { cnt: number };
|
||||
if (count.cnt > 0) {
|
||||
hasEmbeddings = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embeddings for test documents
|
||||
const llm = getDefaultLlamaCpp();
|
||||
store.ensureVecTable(768); // embeddinggemma uses 768 dimensions
|
||||
|
||||
const evalDocsDir = join(import.meta.dir, "../test/eval-docs");
|
||||
const files = readdirSync(evalDocsDir).filter(f => f.endsWith(".md"));
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(evalDocsDir, file), "utf-8");
|
||||
const hash = Bun.hash(content).toString(16).slice(0, 12);
|
||||
const title = content.split("\n")[0]?.replace(/^#\s*/, "") || file;
|
||||
|
||||
// Chunk and embed
|
||||
const chunks = await chunkDocumentByTokens(content);
|
||||
for (let seq = 0; seq < chunks.length; seq++) {
|
||||
const chunk = chunks[seq];
|
||||
if (!chunk) continue;
|
||||
const formatted = formatDocForEmbedding(chunk.text, title);
|
||||
const result = await llm.embed(formatted, { model: DEFAULT_EMBED_MODEL, isQuery: false });
|
||||
if (result?.embedding) {
|
||||
// Convert to Float32Array for sqlite-vec
|
||||
const embedding = new Float32Array(result.embedding);
|
||||
const now = new Date().toISOString();
|
||||
insertEmbedding(db, hash, seq, chunk.pos, embedding, DEFAULT_EMBED_MODEL, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
hasEmbeddings = true;
|
||||
}, 120000); // 2 minute timeout for embedding generation
|
||||
|
||||
afterAll(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
// Note: Don't dispose here - Hybrid tests also use llama.
|
||||
// Dispose happens in the global afterAll.
|
||||
|
||||
test("easy queries: ≥60% Hit@3 (vector should match keywords too)", async () => {
|
||||
if (!hasEmbeddings) return; // Skip if embedding failed
|
||||
|
||||
const easyQueries = evalQueries.filter(q => q.difficulty === "easy");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of easyQueries) {
|
||||
const results = await searchVec(db, query, DEFAULT_EMBED_MODEL, 5);
|
||||
if (results.slice(0, 3).some(r => matchesExpected(r.filepath, expectedDoc))) hits++;
|
||||
}
|
||||
expect(hits / easyQueries.length).toBeGreaterThanOrEqual(0.6);
|
||||
}, 60000);
|
||||
|
||||
test("medium queries: ≥40% Hit@3 (vector excels at semantic)", async () => {
|
||||
if (!hasEmbeddings) return;
|
||||
|
||||
const mediumQueries = evalQueries.filter(q => q.difficulty === "medium");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of mediumQueries) {
|
||||
const results = await searchVec(db, query, DEFAULT_EMBED_MODEL, 5);
|
||||
if (results.slice(0, 3).some(r => matchesExpected(r.filepath, expectedDoc))) hits++;
|
||||
}
|
||||
// Vector search should do better on semantic queries than BM25
|
||||
expect(hits / mediumQueries.length).toBeGreaterThanOrEqual(0.4);
|
||||
}, 60000);
|
||||
|
||||
test("hard queries: ≥30% Hit@5 (vector helps with vague queries)", async () => {
|
||||
if (!hasEmbeddings) return;
|
||||
|
||||
const hardQueries = evalQueries.filter(q => q.difficulty === "hard");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of hardQueries) {
|
||||
const results = await searchVec(db, query, DEFAULT_EMBED_MODEL, 5);
|
||||
if (results.some(r => matchesExpected(r.filepath, expectedDoc))) hits++;
|
||||
}
|
||||
expect(hits / hardQueries.length).toBeGreaterThanOrEqual(0.3);
|
||||
}, 60000);
|
||||
|
||||
test("overall Hit@3 ≥50% (vector baseline)", async () => {
|
||||
if (!hasEmbeddings) return;
|
||||
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of evalQueries) {
|
||||
const results = await searchVec(db, query, DEFAULT_EMBED_MODEL, 5);
|
||||
if (results.slice(0, 3).some(r => matchesExpected(r.filepath, expectedDoc))) hits++;
|
||||
}
|
||||
expect(hits / evalQueries.length).toBeGreaterThanOrEqual(0.5);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Hybrid Search (RRF) Tests - Combines BM25 + Vector
|
||||
// =============================================================================
|
||||
|
||||
describe("Hybrid Search (RRF)", () => {
|
||||
let store: ReturnType<typeof createStore>;
|
||||
let db: Database;
|
||||
let hasVectors = false;
|
||||
|
||||
beforeAll(() => {
|
||||
store = createStore();
|
||||
db = store.db;
|
||||
// Check if vectors exist
|
||||
const vecTable = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`
|
||||
).get();
|
||||
if (vecTable) {
|
||||
const count = db.prepare(`SELECT COUNT(*) as cnt FROM vectors_vec`).get() as { cnt: number };
|
||||
hasVectors = count.cnt > 0;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
// Helper: run hybrid search with RRF fusion
|
||||
async function hybridSearch(query: string, limit: number = 10): Promise<RankedResult[]> {
|
||||
const rankedLists: RankedResult[][] = [];
|
||||
|
||||
// FTS results
|
||||
const ftsResults = searchFTS(db, query, 20);
|
||||
if (ftsResults.length > 0) {
|
||||
rankedLists.push(ftsResults.map(r => ({
|
||||
file: r.filepath,
|
||||
displayPath: r.displayPath,
|
||||
title: r.title,
|
||||
body: r.body || "",
|
||||
score: r.score
|
||||
})));
|
||||
}
|
||||
|
||||
// Vector results
|
||||
const vecResults = await searchVec(db, query, DEFAULT_EMBED_MODEL, 20);
|
||||
if (vecResults.length > 0) {
|
||||
rankedLists.push(vecResults.map(r => ({
|
||||
file: r.filepath,
|
||||
displayPath: r.displayPath,
|
||||
title: r.title,
|
||||
body: r.body || "",
|
||||
score: r.score
|
||||
})));
|
||||
}
|
||||
|
||||
if (rankedLists.length === 0) return [];
|
||||
|
||||
// Apply RRF fusion
|
||||
const fused = reciprocalRankFusion(rankedLists);
|
||||
return fused.slice(0, limit);
|
||||
}
|
||||
|
||||
test("easy queries: ≥80% Hit@3 (hybrid should match BM25)", async () => {
|
||||
const easyQueries = evalQueries.filter(q => q.difficulty === "easy");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of easyQueries) {
|
||||
const results = await hybridSearch(query);
|
||||
if (results.slice(0, 3).some(r => matchesExpected(r.file, expectedDoc))) hits++;
|
||||
}
|
||||
expect(hits / easyQueries.length).toBeGreaterThanOrEqual(0.8);
|
||||
}, 60000);
|
||||
|
||||
test("medium queries: ≥50% Hit@3 with vectors, ≥15% without", async () => {
|
||||
const mediumQueries = evalQueries.filter(q => q.difficulty === "medium");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of mediumQueries) {
|
||||
const results = await hybridSearch(query);
|
||||
if (results.slice(0, 3).some(r => matchesExpected(r.file, expectedDoc))) hits++;
|
||||
}
|
||||
// With vectors: hybrid should outperform both BM25 (15%) and vector (40%)
|
||||
// Without vectors: hybrid is just BM25, so use BM25 threshold
|
||||
const threshold = hasVectors ? 0.5 : 0.15;
|
||||
expect(hits / mediumQueries.length).toBeGreaterThanOrEqual(threshold);
|
||||
}, 60000);
|
||||
|
||||
test("hard queries: ≥35% Hit@5 with vectors, ≥15% without", async () => {
|
||||
const hardQueries = evalQueries.filter(q => q.difficulty === "hard");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of hardQueries) {
|
||||
const results = await hybridSearch(query);
|
||||
if (results.some(r => matchesExpected(r.file, expectedDoc))) hits++;
|
||||
}
|
||||
const threshold = hasVectors ? 0.35 : 0.15;
|
||||
expect(hits / hardQueries.length).toBeGreaterThanOrEqual(threshold);
|
||||
}, 60000);
|
||||
|
||||
test("fusion queries: ≥50% Hit@3 (RRF combines weak signals)", async () => {
|
||||
if (!hasVectors) return; // Fusion requires both methods
|
||||
|
||||
const fusionQueries = evalQueries.filter(q => q.difficulty === "fusion");
|
||||
let hybridHits = 0;
|
||||
let bm25Hits = 0;
|
||||
let vecHits = 0;
|
||||
|
||||
for (const { query, expectedDoc } of fusionQueries) {
|
||||
// Hybrid results
|
||||
const hybridResults = await hybridSearch(query);
|
||||
if (hybridResults.slice(0, 3).some(r => matchesExpected(r.file, expectedDoc))) hybridHits++;
|
||||
|
||||
// BM25 results for comparison
|
||||
const bm25Results = searchFTS(db, query, 5);
|
||||
if (bm25Results.slice(0, 3).some(r => matchesExpected(r.filepath, expectedDoc))) bm25Hits++;
|
||||
|
||||
// Vector results for comparison
|
||||
const vecResults = await searchVec(db, query, DEFAULT_EMBED_MODEL, 5);
|
||||
if (vecResults.slice(0, 3).some(r => matchesExpected(r.filepath, expectedDoc))) vecHits++;
|
||||
}
|
||||
|
||||
const hybridRate = hybridHits / fusionQueries.length;
|
||||
const bm25Rate = bm25Hits / fusionQueries.length;
|
||||
const vecRate = vecHits / fusionQueries.length;
|
||||
|
||||
// Fusion should achieve at least 50% on these multi-signal queries
|
||||
expect(hybridRate).toBeGreaterThanOrEqual(0.5);
|
||||
|
||||
// Fusion should outperform or match the best individual method
|
||||
expect(hybridRate).toBeGreaterThanOrEqual(Math.max(bm25Rate, vecRate));
|
||||
}, 60000);
|
||||
|
||||
test("overall Hit@3 ≥60% with vectors, ≥40% without", async () => {
|
||||
// Filter out fusion queries for overall score (they're tested separately)
|
||||
const standardQueries = evalQueries.filter(q => q.difficulty !== "fusion");
|
||||
let hits = 0;
|
||||
for (const { query, expectedDoc } of standardQueries) {
|
||||
const results = await hybridSearch(query);
|
||||
if (results.slice(0, 3).some(r => matchesExpected(r.file, expectedDoc))) hits++;
|
||||
}
|
||||
const threshold = hasVectors ? 0.6 : 0.4;
|
||||
expect(hits / standardQueries.length).toBeGreaterThanOrEqual(threshold);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup
|
||||
// =============================================================================
|
||||
|
||||
afterAll(async () => {
|
||||
// Ensure native resources are released to avoid ggml-metal asserts on process exit.
|
||||
await disposeDefaultLlamaCpp();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -1,427 +0,0 @@
|
||||
/**
|
||||
* formatter.ts - Output formatting utilities for QMD
|
||||
*
|
||||
* Provides methods to format search results and documents into various output formats:
|
||||
* JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
|
||||
*/
|
||||
|
||||
import { extractSnippet } from "./store.js";
|
||||
import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
// Re-export store types for convenience
|
||||
export type { SearchResult, MultiGetResult, DocumentResult };
|
||||
|
||||
// Flattened type for formatter convenience (extracts info from MultiGetResult)
|
||||
export type MultiGetFile = {
|
||||
filepath: string;
|
||||
displayPath: string;
|
||||
title: string;
|
||||
body: string;
|
||||
context?: string | null;
|
||||
skipped: false;
|
||||
} | {
|
||||
filepath: string;
|
||||
displayPath: string;
|
||||
title: string;
|
||||
body: string;
|
||||
context?: string | null;
|
||||
skipped: true;
|
||||
skipReason: string;
|
||||
};
|
||||
|
||||
export type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
|
||||
|
||||
export type FormatOptions = {
|
||||
full?: boolean; // Show full document content instead of snippet
|
||||
query?: string; // Query for snippet extraction and highlighting
|
||||
useColor?: boolean; // Enable terminal colors (default: false for non-CLI)
|
||||
lineNumbers?: boolean;// Add line numbers to output
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Add line numbers to text content.
|
||||
* Each line becomes: "{lineNum}: {content}"
|
||||
* @param text The text to add line numbers to
|
||||
* @param startLine Optional starting line number (default: 1)
|
||||
*/
|
||||
export function addLineNumbers(text: string, startLine: number = 1): string {
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short docid from a full hash (first 6 characters).
|
||||
*/
|
||||
export function getDocid(hash: string): string {
|
||||
return hash.slice(0, 6);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Escape Helpers
|
||||
// =============================================================================
|
||||
|
||||
export function escapeCSV(value: string | null | number): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str = String(value);
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Search Results Formatters
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format search results as JSON
|
||||
*/
|
||||
export function searchResultsToJson(
|
||||
results: SearchResult[],
|
||||
opts: FormatOptions = {}
|
||||
): string {
|
||||
const query = opts.query || "";
|
||||
const output = results.map(row => {
|
||||
const bodyStr = row.body || "";
|
||||
let body = opts.full ? bodyStr : undefined;
|
||||
let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos).snippet : undefined;
|
||||
|
||||
if (opts.lineNumbers) {
|
||||
if (body) body = addLineNumbers(body);
|
||||
if (snippet) snippet = addLineNumbers(snippet);
|
||||
}
|
||||
|
||||
return {
|
||||
docid: `#${row.docid}`,
|
||||
score: Math.round(row.score * 100) / 100,
|
||||
file: row.displayPath,
|
||||
title: row.title,
|
||||
...(row.context && { context: row.context }),
|
||||
...(body && { body }),
|
||||
...(snippet && { snippet }),
|
||||
};
|
||||
});
|
||||
return JSON.stringify(output, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results as CSV
|
||||
*/
|
||||
export function searchResultsToCsv(
|
||||
results: SearchResult[],
|
||||
opts: FormatOptions = {}
|
||||
): string {
|
||||
const query = opts.query || "";
|
||||
const header = "docid,score,file,title,context,line,snippet";
|
||||
const rows = results.map(row => {
|
||||
const bodyStr = row.body || "";
|
||||
const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos);
|
||||
let content = opts.full ? bodyStr : snippet;
|
||||
if (opts.lineNumbers && content) {
|
||||
content = addLineNumbers(content);
|
||||
}
|
||||
return [
|
||||
`#${row.docid}`,
|
||||
row.score.toFixed(4),
|
||||
escapeCSV(row.displayPath),
|
||||
escapeCSV(row.title),
|
||||
escapeCSV(row.context || ""),
|
||||
line,
|
||||
escapeCSV(content),
|
||||
].join(",");
|
||||
});
|
||||
return [header, ...rows].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results as simple files list (docid,score,filepath,context)
|
||||
*/
|
||||
export function searchResultsToFiles(results: SearchResult[]): string {
|
||||
return results.map(row => {
|
||||
const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
|
||||
return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results as Markdown
|
||||
*/
|
||||
export function searchResultsToMarkdown(
|
||||
results: SearchResult[],
|
||||
opts: FormatOptions = {}
|
||||
): string {
|
||||
const query = opts.query || "";
|
||||
return results.map(row => {
|
||||
const heading = row.title || row.displayPath;
|
||||
const bodyStr = row.body || "";
|
||||
let content: string;
|
||||
if (opts.full) {
|
||||
content = bodyStr;
|
||||
} else {
|
||||
content = extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
|
||||
}
|
||||
if (opts.lineNumbers) {
|
||||
content = addLineNumbers(content);
|
||||
}
|
||||
return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n\n${content}\n`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results as XML
|
||||
*/
|
||||
export function searchResultsToXml(
|
||||
results: SearchResult[],
|
||||
opts: FormatOptions = {}
|
||||
): string {
|
||||
const query = opts.query || "";
|
||||
const items = results.map(row => {
|
||||
const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
|
||||
const bodyStr = row.body || "";
|
||||
let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
|
||||
if (opts.lineNumbers) {
|
||||
content = addLineNumbers(content);
|
||||
}
|
||||
return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}>\n${escapeXml(content)}\n</file>`;
|
||||
});
|
||||
return items.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for MCP (simpler CSV format with pre-extracted snippets)
|
||||
*/
|
||||
export function searchResultsToMcpCsv(
|
||||
results: { docid: string; file: string; title: string; score: number; context: string | null; snippet: string }[]
|
||||
): string {
|
||||
const header = "docid,file,title,score,context,snippet";
|
||||
const rows = results.map(r =>
|
||||
[`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(",")
|
||||
);
|
||||
return [header, ...rows].join("\n");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Document Formatters (for multi-get using MultiGetFile from store)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format documents as JSON
|
||||
*/
|
||||
export function documentsToJson(results: MultiGetFile[]): string {
|
||||
const output = results.map(r => ({
|
||||
file: r.displayPath,
|
||||
title: r.title,
|
||||
...(r.context && { context: r.context }),
|
||||
...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
|
||||
}));
|
||||
return JSON.stringify(output, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format documents as CSV
|
||||
*/
|
||||
export function documentsToCsv(results: MultiGetFile[]): string {
|
||||
const header = "file,title,context,skipped,body";
|
||||
const rows = results.map(r =>
|
||||
[
|
||||
r.displayPath,
|
||||
r.title,
|
||||
r.context || "",
|
||||
r.skipped ? "true" : "false",
|
||||
r.skipped ? (r.skipReason || "") : r.body
|
||||
].map(escapeCSV).join(",")
|
||||
);
|
||||
return [header, ...rows].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format documents as files list
|
||||
*/
|
||||
export function documentsToFiles(results: MultiGetFile[]): string {
|
||||
return results.map(r => {
|
||||
const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
|
||||
const status = r.skipped ? ",[SKIPPED]" : "";
|
||||
return `${r.displayPath}${ctx}${status}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format documents as Markdown
|
||||
*/
|
||||
export function documentsToMarkdown(results: MultiGetFile[]): string {
|
||||
return results.map(r => {
|
||||
let md = `## ${r.displayPath}\n\n`;
|
||||
if (r.title && r.title !== r.displayPath) md += `**Title:** ${r.title}\n\n`;
|
||||
if (r.context) md += `**Context:** ${r.context}\n\n`;
|
||||
if (r.skipped) {
|
||||
md += `> ${r.skipReason}\n`;
|
||||
} else {
|
||||
md += "```\n" + r.body + "\n```\n";
|
||||
}
|
||||
return md;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format documents as XML
|
||||
*/
|
||||
export function documentsToXml(results: MultiGetFile[]): string {
|
||||
const items = results.map(r => {
|
||||
let xml = " <document>\n";
|
||||
xml += ` <file>${escapeXml(r.displayPath)}</file>\n`;
|
||||
xml += ` <title>${escapeXml(r.title)}</title>\n`;
|
||||
if (r.context) xml += ` <context>${escapeXml(r.context)}</context>\n`;
|
||||
if (r.skipped) {
|
||||
xml += ` <skipped>true</skipped>\n`;
|
||||
xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`;
|
||||
} else {
|
||||
xml += ` <body>${escapeXml(r.body)}</body>\n`;
|
||||
}
|
||||
xml += " </document>";
|
||||
return xml;
|
||||
});
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Single Document Formatters
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format a single DocumentResult as JSON
|
||||
*/
|
||||
export function documentToJson(doc: DocumentResult): string {
|
||||
return JSON.stringify({
|
||||
file: doc.displayPath,
|
||||
title: doc.title,
|
||||
...(doc.context && { context: doc.context }),
|
||||
hash: doc.hash,
|
||||
modifiedAt: doc.modifiedAt,
|
||||
bodyLength: doc.bodyLength,
|
||||
...(doc.body !== undefined && { body: doc.body }),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single DocumentResult as Markdown
|
||||
*/
|
||||
export function documentToMarkdown(doc: DocumentResult): string {
|
||||
let md = `# ${doc.title || doc.displayPath}\n\n`;
|
||||
if (doc.context) md += `**Context:** ${doc.context}\n\n`;
|
||||
md += `**File:** ${doc.displayPath}\n`;
|
||||
md += `**Modified:** ${doc.modifiedAt}\n\n`;
|
||||
if (doc.body !== undefined) {
|
||||
md += "---\n\n" + doc.body + "\n";
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single DocumentResult as XML
|
||||
*/
|
||||
export function documentToXml(doc: DocumentResult): string {
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`;
|
||||
xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`;
|
||||
xml += ` <title>${escapeXml(doc.title)}</title>\n`;
|
||||
if (doc.context) xml += ` <context>${escapeXml(doc.context)}</context>\n`;
|
||||
xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`;
|
||||
xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`;
|
||||
xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`;
|
||||
if (doc.body !== undefined) {
|
||||
xml += ` <body>${escapeXml(doc.body)}</body>\n`;
|
||||
}
|
||||
xml += `</document>`;
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single document to the specified format
|
||||
*/
|
||||
export function formatDocument(doc: DocumentResult, format: OutputFormat): string {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return documentToJson(doc);
|
||||
case "md":
|
||||
return documentToMarkdown(doc);
|
||||
case "xml":
|
||||
return documentToXml(doc);
|
||||
default:
|
||||
// Default to markdown for CLI and other formats
|
||||
return documentToMarkdown(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Universal Format Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format search results to the specified output format
|
||||
*/
|
||||
export function formatSearchResults(
|
||||
results: SearchResult[],
|
||||
format: OutputFormat,
|
||||
opts: FormatOptions = {}
|
||||
): string {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return searchResultsToJson(results, opts);
|
||||
case "csv":
|
||||
return searchResultsToCsv(results, opts);
|
||||
case "files":
|
||||
return searchResultsToFiles(results);
|
||||
case "md":
|
||||
return searchResultsToMarkdown(results, opts);
|
||||
case "xml":
|
||||
return searchResultsToXml(results, opts);
|
||||
case "cli":
|
||||
// CLI format should be handled separately with colors
|
||||
// Return a simple text version as fallback
|
||||
return searchResultsToMarkdown(results, opts);
|
||||
default:
|
||||
return searchResultsToJson(results, opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format documents to the specified output format
|
||||
*/
|
||||
export function formatDocuments(
|
||||
results: MultiGetFile[],
|
||||
format: OutputFormat
|
||||
): string {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return documentsToJson(results);
|
||||
case "csv":
|
||||
return documentsToCsv(results);
|
||||
case "files":
|
||||
return documentsToFiles(results);
|
||||
case "md":
|
||||
return documentsToMarkdown(results);
|
||||
case "xml":
|
||||
return documentsToXml(results);
|
||||
case "cli":
|
||||
// CLI format should be handled separately with colors
|
||||
return documentsToMarkdown(results);
|
||||
default:
|
||||
return documentsToJson(results);
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
/**
|
||||
* llm.test.ts - Unit tests for the LLM abstraction layer (node-llama-cpp)
|
||||
*
|
||||
* Run with: bun test src/llm.test.ts
|
||||
*
|
||||
* These tests require the actual models to be downloaded. Run the embed or
|
||||
* rerank functions first to trigger model downloads.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import {
|
||||
LlamaCpp,
|
||||
getDefaultLlamaCpp,
|
||||
disposeDefaultLlamaCpp,
|
||||
withLLMSession,
|
||||
canUnloadLLM,
|
||||
SessionReleasedError,
|
||||
type RerankDocument,
|
||||
type ILLMSession,
|
||||
} from "./llm.js";
|
||||
|
||||
// =============================================================================
|
||||
// Singleton Tests (no model loading required)
|
||||
// =============================================================================
|
||||
|
||||
describe("Default LlamaCpp Singleton", () => {
|
||||
// Test singleton behavior without resetting to avoid orphan instances
|
||||
test("getDefaultLlamaCpp returns same instance on subsequent calls", () => {
|
||||
const llm1 = getDefaultLlamaCpp();
|
||||
const llm2 = getDefaultLlamaCpp();
|
||||
expect(llm1).toBe(llm2);
|
||||
expect(llm1).toBeInstanceOf(LlamaCpp);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Model Existence Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("LlamaCpp.modelExists", () => {
|
||||
test("returns exists:true for HuggingFace model URIs", async () => {
|
||||
const llm = getDefaultLlamaCpp();
|
||||
const result = await llm.modelExists("hf:org/repo/model.gguf");
|
||||
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.name).toBe("hf:org/repo/model.gguf");
|
||||
});
|
||||
|
||||
test("returns exists:false for non-existent local paths", async () => {
|
||||
const llm = getDefaultLlamaCpp();
|
||||
const result = await llm.modelExists("/nonexistent/path/model.gguf");
|
||||
|
||||
expect(result.exists).toBe(false);
|
||||
expect(result.name).toBe("/nonexistent/path/model.gguf");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Integration Tests (require actual models)
|
||||
// =============================================================================
|
||||
|
||||
describe("LlamaCpp Integration", () => {
|
||||
// Use the singleton to avoid multiple Metal contexts
|
||||
const llm = getDefaultLlamaCpp();
|
||||
|
||||
afterAll(async () => {
|
||||
// Ensure native resources are released to avoid ggml-metal asserts on process exit.
|
||||
await disposeDefaultLlamaCpp();
|
||||
});
|
||||
|
||||
describe("embed", () => {
|
||||
test("returns embedding with correct dimensions", async () => {
|
||||
const result = await llm.embed("Hello world");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.embedding).toBeInstanceOf(Array);
|
||||
expect(result!.embedding.length).toBeGreaterThan(0);
|
||||
// embeddinggemma outputs 768 dimensions
|
||||
expect(result!.embedding.length).toBe(768);
|
||||
});
|
||||
|
||||
test("returns consistent embeddings for same input", async () => {
|
||||
const result1 = await llm.embed("test text");
|
||||
const result2 = await llm.embed("test text");
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
|
||||
// Embeddings should be identical for the same input
|
||||
for (let i = 0; i < result1!.embedding.length; i++) {
|
||||
expect(result1!.embedding[i]).toBeCloseTo(result2!.embedding[i]!, 5);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns different embeddings for different inputs", async () => {
|
||||
const result1 = await llm.embed("cats are great");
|
||||
const result2 = await llm.embed("database optimization");
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
|
||||
// Calculate cosine similarity - should be less than 1.0 (not identical)
|
||||
let dotProduct = 0;
|
||||
let norm1 = 0;
|
||||
let norm2 = 0;
|
||||
for (let i = 0; i < result1!.embedding.length; i++) {
|
||||
const v1 = result1!.embedding[i]!;
|
||||
const v2 = result2!.embedding[i]!;
|
||||
dotProduct += v1 * v2;
|
||||
norm1 += v1 ** 2;
|
||||
norm2 += v2 ** 2;
|
||||
}
|
||||
const similarity = dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
|
||||
|
||||
expect(similarity).toBeLessThan(0.95); // Should be meaningfully different
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedBatch", () => {
|
||||
test("returns embeddings for multiple texts", async () => {
|
||||
const texts = ["Hello world", "Test text", "Another document"];
|
||||
const results = await llm.embedBatch(texts);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
for (const result of results) {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.embedding.length).toBe(768);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns same results as individual embed calls", async () => {
|
||||
const texts = ["cats are great", "dogs are awesome"];
|
||||
|
||||
// Get batch embeddings
|
||||
const batchResults = await llm.embedBatch(texts);
|
||||
|
||||
// Get individual embeddings
|
||||
const individualResults = await Promise.all(texts.map(t => llm.embed(t)));
|
||||
|
||||
// Compare - should be identical
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
expect(batchResults[i]).not.toBeNull();
|
||||
expect(individualResults[i]).not.toBeNull();
|
||||
for (let j = 0; j < batchResults[i]!.embedding.length; j++) {
|
||||
expect(batchResults[i]!.embedding[j]).toBeCloseTo(individualResults[i]!.embedding[j]!, 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("handles empty array", async () => {
|
||||
const results = await llm.embedBatch([]);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("batch is faster than sequential", async () => {
|
||||
const texts = Array(10).fill(null).map((_, i) => `Document number ${i} with content`);
|
||||
|
||||
// Time batch
|
||||
const batchStart = Date.now();
|
||||
await llm.embedBatch(texts);
|
||||
const batchTime = Date.now() - batchStart;
|
||||
|
||||
// Time sequential
|
||||
const seqStart = Date.now();
|
||||
for (const text of texts) {
|
||||
await llm.embed(text);
|
||||
}
|
||||
const seqTime = Date.now() - seqStart;
|
||||
|
||||
console.log(`Batch: ${batchTime}ms, Sequential: ${seqTime}ms`);
|
||||
// Performance is machine/load dependent. We only assert batch isn't drastically worse.
|
||||
expect(batchTime).toBeLessThanOrEqual(seqTime * 3);
|
||||
});
|
||||
|
||||
test("handles concurrent embedBatch calls on fresh instance without race condition", async () => {
|
||||
// This test verifies the fix for a race condition where concurrent calls to
|
||||
// ensureEmbedContext() could create multiple contexts. Without the promise guard,
|
||||
// each concurrent embedBatch call sees embedContext === null and creates its own
|
||||
// context, causing resource leaks and potential "Context is disposed" errors.
|
||||
//
|
||||
// See: https://github.com/tobi/qmd/pull/54
|
||||
//
|
||||
// The fix uses a promise guard to ensure only one context creation runs at a time.
|
||||
// We verify this by instrumenting createEmbeddingContext to count invocations.
|
||||
|
||||
const freshLlm = new LlamaCpp({});
|
||||
let contextCreateCount = 0;
|
||||
|
||||
// Instrument the model's createEmbeddingContext to count calls
|
||||
const originalEnsureEmbedModel = (freshLlm as any).ensureEmbedModel.bind(freshLlm);
|
||||
let modelInstrumented = false;
|
||||
(freshLlm as any).ensureEmbedModel = async function() {
|
||||
const model = await originalEnsureEmbedModel();
|
||||
if (!modelInstrumented) {
|
||||
modelInstrumented = true;
|
||||
const originalCreate = model.createEmbeddingContext.bind(model);
|
||||
model.createEmbeddingContext = async function(...args: any[]) {
|
||||
contextCreateCount++;
|
||||
return originalCreate(...args);
|
||||
};
|
||||
}
|
||||
return model;
|
||||
};
|
||||
|
||||
const texts = Array(10).fill(null).map((_, i) => `Document ${i}`);
|
||||
|
||||
// Call embedBatch 5 TIMES in parallel on fresh instance.
|
||||
// Without the promise guard fix, this would create 5 contexts (one per call).
|
||||
// With the fix, only 1 context should be created.
|
||||
const batches = await Promise.all([
|
||||
freshLlm.embedBatch(texts.slice(0, 2)),
|
||||
freshLlm.embedBatch(texts.slice(2, 4)),
|
||||
freshLlm.embedBatch(texts.slice(4, 6)),
|
||||
freshLlm.embedBatch(texts.slice(6, 8)),
|
||||
freshLlm.embedBatch(texts.slice(8, 10)),
|
||||
]);
|
||||
|
||||
const allResults = batches.flat();
|
||||
expect(allResults).toHaveLength(10);
|
||||
|
||||
const successCount = allResults.filter(r => r !== null).length;
|
||||
expect(successCount).toBe(10);
|
||||
|
||||
// THE KEY ASSERTION: Only 1 context should be created, not 5
|
||||
// Without the fix, contextCreateCount would be 5 (one per concurrent embedBatch call)
|
||||
console.log(`Context creation count: ${contextCreateCount} (expected: 1)`);
|
||||
expect(contextCreateCount).toBe(1);
|
||||
|
||||
await freshLlm.dispose();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("rerank", () => {
|
||||
test("scores capital of France question correctly", async () => {
|
||||
const query = "What is the capital of France?";
|
||||
const documents: RerankDocument[] = [
|
||||
{ file: "butterflies.txt", text: "Butterflies indeed fly through the garden." },
|
||||
{ file: "france.txt", text: "The capital of France is Paris." },
|
||||
{ file: "canada.txt", text: "The capital of Canada is Ottawa." },
|
||||
];
|
||||
|
||||
const result = await llm.rerank(query, documents);
|
||||
|
||||
expect(result.results).toHaveLength(3);
|
||||
|
||||
// The France document should score highest
|
||||
expect(result.results[0]!.file).toBe("france.txt");
|
||||
expect(result.results[0]!.score).toBeGreaterThan(0.7);
|
||||
|
||||
// Canada should be somewhat relevant (also about capitals)
|
||||
expect(result.results[1]!.file).toBe("canada.txt");
|
||||
|
||||
// Butterflies should score lowest
|
||||
expect(result.results[2]!.file).toBe("butterflies.txt");
|
||||
expect(result.results[2]!.score).toBeLessThan(0.6);
|
||||
});
|
||||
|
||||
test("scores authentication query correctly", async () => {
|
||||
const query = "How do I configure authentication?";
|
||||
const documents: RerankDocument[] = [
|
||||
{ file: "weather.md", text: "The weather today is sunny with mild temperatures." },
|
||||
{ file: "auth.md", text: "Authentication can be configured by setting the AUTH_SECRET environment variable." },
|
||||
{ file: "pizza.md", text: "Our restaurant serves the best pizza in town." },
|
||||
{ file: "jwt.md", text: "JWT authentication requires a secret key and expiration time." },
|
||||
];
|
||||
|
||||
const result = await llm.rerank(query, documents);
|
||||
|
||||
expect(result.results).toHaveLength(4);
|
||||
|
||||
// Auth documents should score highest
|
||||
const topTwo = result.results.slice(0, 2).map((r) => r.file);
|
||||
expect(topTwo).toContain("auth.md");
|
||||
expect(topTwo).toContain("jwt.md");
|
||||
|
||||
// Irrelevant documents should score lowest
|
||||
const bottomTwo = result.results.slice(2).map((r) => r.file);
|
||||
expect(bottomTwo).toContain("weather.md");
|
||||
expect(bottomTwo).toContain("pizza.md");
|
||||
});
|
||||
|
||||
test("handles programming queries correctly", async () => {
|
||||
const query = "How do I handle errors in JavaScript?";
|
||||
const documents: RerankDocument[] = [
|
||||
{ file: "cooking.md", text: "To make a good pasta, boil water and add salt." },
|
||||
{ file: "errors.md", text: "Use try-catch blocks to handle JavaScript errors gracefully." },
|
||||
{ file: "python.md", text: "Python uses try-except for exception handling." },
|
||||
];
|
||||
|
||||
const result = await llm.rerank(query, documents);
|
||||
|
||||
// JavaScript errors doc should score highest
|
||||
expect(result.results[0]!.file).toBe("errors.md");
|
||||
expect(result.results[0]!.score).toBeGreaterThan(0.7);
|
||||
|
||||
// Python doc might be somewhat relevant (same concept, different language)
|
||||
// Cooking should be least relevant
|
||||
expect(result.results[2]!.file).toBe("cooking.md");
|
||||
});
|
||||
|
||||
test("handles empty document list", async () => {
|
||||
const result = await llm.rerank("test query", []);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles single document", async () => {
|
||||
const result = await llm.rerank("test", [{ file: "doc.md", text: "content" }]);
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0]!.file).toBe("doc.md");
|
||||
});
|
||||
|
||||
test("preserves original file paths", async () => {
|
||||
const documents: RerankDocument[] = [
|
||||
{ file: "path/to/doc1.md", text: "content one" },
|
||||
{ file: "another/path/doc2.md", text: "content two" },
|
||||
];
|
||||
|
||||
const result = await llm.rerank("query", documents);
|
||||
|
||||
const files = result.results.map((r) => r.file).sort();
|
||||
expect(files).toEqual(["another/path/doc2.md", "path/to/doc1.md"]);
|
||||
});
|
||||
|
||||
test("returns scores between 0 and 1", async () => {
|
||||
const documents: RerankDocument[] = [
|
||||
{ file: "a.md", text: "The quick brown fox jumps over the lazy dog." },
|
||||
{ file: "b.md", text: "Machine learning algorithms process data efficiently." },
|
||||
{ file: "c.md", text: "React components use JSX syntax for rendering." },
|
||||
];
|
||||
|
||||
const result = await llm.rerank("Tell me about animals", documents);
|
||||
|
||||
for (const doc of result.results) {
|
||||
expect(doc.score).toBeGreaterThanOrEqual(0);
|
||||
expect(doc.score).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("batch reranks multiple documents efficiently", async () => {
|
||||
// Create 10 documents to verify batch processing works
|
||||
const documents: RerankDocument[] = Array(10)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
file: `doc${i}.md`,
|
||||
text: `Document number ${i} with some content about topic ${i % 3}`,
|
||||
}));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await llm.rerank("topic 1", documents);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result.results).toHaveLength(10);
|
||||
|
||||
// Verify all documents are returned with valid scores
|
||||
for (const doc of result.results) {
|
||||
expect(doc.score).toBeGreaterThanOrEqual(0);
|
||||
expect(doc.score).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Log timing for monitoring batch performance
|
||||
console.log(`Batch rerank of 10 docs took ${elapsed}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandQuery", () => {
|
||||
test("returns query expansions with correct types", async () => {
|
||||
const result = await llm.expandQuery("test query");
|
||||
|
||||
// Result is Queryable[] containing lex, vec, and/or hyde entries
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Each result should have a valid type
|
||||
for (const q of result) {
|
||||
expect(["lex", "vec", "hyde"]).toContain(q.type);
|
||||
expect(q.text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 30000); // 30s timeout for model loading
|
||||
|
||||
test("can exclude lexical queries", async () => {
|
||||
const result = await llm.expandQuery("authentication setup", { includeLexical: false });
|
||||
|
||||
// Should not contain any 'lex' type entries
|
||||
const lexEntries = result.filter(q => q.type === "lex");
|
||||
expect(lexEntries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Session Management Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("LLM Session Management", () => {
|
||||
describe("withLLMSession", () => {
|
||||
test("session provides access to LLM operations", async () => {
|
||||
const result = await withLLMSession(async (session) => {
|
||||
expect(session.isValid).toBe(true);
|
||||
const embedding = await session.embed("test text");
|
||||
expect(embedding).not.toBeNull();
|
||||
expect(embedding!.embedding.length).toBe(768);
|
||||
return "success";
|
||||
});
|
||||
expect(result).toBe("success");
|
||||
});
|
||||
|
||||
test("session is invalid after release", async () => {
|
||||
let capturedSession: ILLMSession | null = null;
|
||||
|
||||
await withLLMSession(async (session) => {
|
||||
capturedSession = session;
|
||||
expect(session.isValid).toBe(true);
|
||||
});
|
||||
|
||||
// Session should be invalid after withLLMSession returns
|
||||
expect(capturedSession).not.toBeNull();
|
||||
expect(capturedSession!.isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("session prevents idle unload during operations", async () => {
|
||||
await withLLMSession(async (session) => {
|
||||
// While inside a session, canUnloadLLM should return false
|
||||
expect(canUnloadLLM()).toBe(false);
|
||||
|
||||
// Perform an operation
|
||||
await session.embed("test");
|
||||
|
||||
// Still should not be able to unload
|
||||
expect(canUnloadLLM()).toBe(false);
|
||||
});
|
||||
|
||||
// After session ends, should be able to unload
|
||||
expect(canUnloadLLM()).toBe(true);
|
||||
});
|
||||
|
||||
test("nested sessions increment ref count", async () => {
|
||||
await withLLMSession(async (outerSession) => {
|
||||
expect(canUnloadLLM()).toBe(false);
|
||||
|
||||
await withLLMSession(async (innerSession) => {
|
||||
expect(canUnloadLLM()).toBe(false);
|
||||
expect(innerSession.isValid).toBe(true);
|
||||
expect(outerSession.isValid).toBe(true);
|
||||
});
|
||||
|
||||
// Inner session released, but outer still active
|
||||
expect(canUnloadLLM()).toBe(false);
|
||||
expect(outerSession.isValid).toBe(true);
|
||||
});
|
||||
|
||||
// All sessions released
|
||||
expect(canUnloadLLM()).toBe(true);
|
||||
});
|
||||
|
||||
test("session embedBatch works correctly", async () => {
|
||||
await withLLMSession(async (session) => {
|
||||
const texts = ["Hello world", "Test text", "Another document"];
|
||||
const results = await session.embedBatch(texts);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
for (const result of results) {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.embedding.length).toBe(768);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("session rerank works correctly", async () => {
|
||||
await withLLMSession(async (session) => {
|
||||
const documents: RerankDocument[] = [
|
||||
{ file: "a.txt", text: "The capital of France is Paris." },
|
||||
{ file: "b.txt", text: "Dogs are great pets." },
|
||||
];
|
||||
|
||||
const result = await session.rerank("What is the capital of France?", documents);
|
||||
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0]!.file).toBe("a.txt");
|
||||
expect(result.results[0]!.score).toBeGreaterThan(result.results[1]!.score);
|
||||
});
|
||||
});
|
||||
|
||||
test("max duration aborts session after timeout", async () => {
|
||||
let aborted = false;
|
||||
|
||||
try {
|
||||
await withLLMSession(async (session) => {
|
||||
// Wait longer than max duration
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// This operation should throw because session was aborted
|
||||
await session.embed("test");
|
||||
}, { maxDuration: 50 }); // 50ms max
|
||||
} catch (err) {
|
||||
if (err instanceof SessionReleasedError) {
|
||||
aborted = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
expect(aborted).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
test("external abort signal propagates to session", async () => {
|
||||
const abortController = new AbortController();
|
||||
let sessionAborted = false;
|
||||
|
||||
const promise = withLLMSession(async (session) => {
|
||||
// Wait a bit then check if aborted
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (!session.isValid) {
|
||||
sessionAborted = true;
|
||||
throw new SessionReleasedError("Session aborted");
|
||||
}
|
||||
|
||||
return "should not reach";
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
// Abort after 20ms
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} catch (err) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
expect(sessionAborted).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
test("session provides abort signal for monitoring", async () => {
|
||||
await withLLMSession(async (session) => {
|
||||
expect(session.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(session.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns value from callback", async () => {
|
||||
const result = await withLLMSession(async (session) => {
|
||||
await session.embed("test");
|
||||
return { status: "complete", count: 42 };
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "complete", count: 42 });
|
||||
});
|
||||
|
||||
test("propagates errors from callback", async () => {
|
||||
const customError = new Error("Custom test error");
|
||||
|
||||
await expect(
|
||||
withLLMSession(async () => {
|
||||
throw customError;
|
||||
})
|
||||
).rejects.toThrow("Custom test error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1208
tools/qmd/src/llm.ts
1208
tools/qmd/src/llm.ts
File diff suppressed because it is too large
Load Diff
@@ -1,889 +0,0 @@
|
||||
/**
|
||||
* MCP Server Tests
|
||||
*
|
||||
* Tests all MCP tools, resources, and prompts.
|
||||
* Uses mocked Ollama responses and a test database.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import * as sqliteVec from "sqlite-vec";
|
||||
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { getDefaultLlamaCpp, disposeDefaultLlamaCpp } from "./llm";
|
||||
import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import YAML from "yaml";
|
||||
import type { CollectionConfig } from "./collections";
|
||||
|
||||
// =============================================================================
|
||||
// Test Database Setup
|
||||
// =============================================================================
|
||||
|
||||
let testDb: Database;
|
||||
let testDbPath: string;
|
||||
let testConfigDir: string;
|
||||
|
||||
afterAll(async () => {
|
||||
// Ensure native resources are released to avoid ggml-metal asserts on process exit.
|
||||
await disposeDefaultLlamaCpp();
|
||||
});
|
||||
|
||||
function initTestDatabase(db: Database): void {
|
||||
sqliteVec.load(db);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
// Content-addressable storage - the source of truth for document content
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS content (
|
||||
hash TEXT PRIMARY KEY,
|
||||
doc TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Documents table - file system layer mapping virtual paths to content hashes
|
||||
// Collections are now managed in YAML config
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (hash) REFERENCES content(hash) ON DELETE CASCADE,
|
||||
UNIQUE(collection, path)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection, active)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS llm_cache (
|
||||
hash TEXT PRIMARY KEY,
|
||||
result TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS content_vectors (
|
||||
hash TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL DEFAULT 0,
|
||||
pos INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT NOT NULL,
|
||||
embedded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (hash, seq)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
||||
name, body,
|
||||
content='documents',
|
||||
content_rowid='id',
|
||||
tokenize='porter unicode61'
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(rowid, name, body)
|
||||
SELECT new.id, new.path, content.doc
|
||||
FROM content
|
||||
WHERE content.hash = new.hash;
|
||||
END
|
||||
`);
|
||||
|
||||
// Create vector table
|
||||
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[768] distance_metric=cosine)`);
|
||||
}
|
||||
|
||||
function seedTestData(db: Database): void {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Note: Collections are now managed in YAML config, not in database
|
||||
// For tests, we'll use a collection name "docs"
|
||||
|
||||
// Add test documents
|
||||
const docs = [
|
||||
{
|
||||
path: "readme.md",
|
||||
title: "Project README",
|
||||
hash: "hash1",
|
||||
body: "# Project README\n\nThis is the main readme file for the project.\n\nIt contains important information about setup and usage.",
|
||||
},
|
||||
{
|
||||
path: "api.md",
|
||||
title: "API Documentation",
|
||||
hash: "hash2",
|
||||
body: "# API Documentation\n\nThis document describes the REST API endpoints.\n\n## Authentication\n\nUse Bearer tokens for auth.",
|
||||
},
|
||||
{
|
||||
path: "meetings/meeting-2024-01.md",
|
||||
title: "January Meeting Notes",
|
||||
hash: "hash3",
|
||||
body: "# January Meeting Notes\n\nDiscussed Q1 goals and roadmap.\n\n## Action Items\n\n- Review budget\n- Hire new team members",
|
||||
},
|
||||
{
|
||||
path: "meetings/meeting-2024-02.md",
|
||||
title: "February Meeting Notes",
|
||||
hash: "hash4",
|
||||
body: "# February Meeting Notes\n\nFollowed up on Q1 progress.\n\n## Updates\n\n- Budget approved\n- Two candidates interviewed",
|
||||
},
|
||||
{
|
||||
path: "large-file.md",
|
||||
title: "Large Document",
|
||||
hash: "hash5",
|
||||
body: "# Large Document\n\n" + "Lorem ipsum ".repeat(2000), // ~24KB
|
||||
},
|
||||
];
|
||||
|
||||
for (const doc of docs) {
|
||||
// Insert content first
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO content (hash, doc, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(doc.hash, doc.body, now);
|
||||
|
||||
// Then insert document metadata
|
||||
db.prepare(`
|
||||
INSERT INTO documents (collection, path, title, hash, created_at, modified_at, active)
|
||||
VALUES ('docs', ?, ?, ?, ?, ?, 1)
|
||||
`).run(doc.path, doc.title, doc.hash, now, now);
|
||||
}
|
||||
|
||||
// Add embeddings for vector search
|
||||
const embedding = new Float32Array(768);
|
||||
for (let i = 0; i < 768; i++) embedding[i] = Math.random();
|
||||
|
||||
for (const doc of docs.slice(0, 4)) { // Skip large file for embeddings
|
||||
db.prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`).run(doc.hash, now);
|
||||
db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(`${doc.hash}_0`, embedding);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MCP Server Test Helpers
|
||||
// =============================================================================
|
||||
|
||||
// We need to create a testable version of the MCP handlers
|
||||
// Since McpServer uses internal routing, we'll test the handler functions directly
|
||||
|
||||
import {
|
||||
searchFTS,
|
||||
searchVec,
|
||||
expandQuery,
|
||||
rerank,
|
||||
reciprocalRankFusion,
|
||||
extractSnippet,
|
||||
getContextForFile,
|
||||
findDocument,
|
||||
getDocumentBody,
|
||||
findDocuments,
|
||||
getStatus,
|
||||
DEFAULT_EMBED_MODEL,
|
||||
DEFAULT_QUERY_MODEL,
|
||||
DEFAULT_RERANK_MODEL,
|
||||
DEFAULT_MULTI_GET_MAX_BYTES,
|
||||
createStore,
|
||||
} from "./store";
|
||||
import type { RankedResult } from "./store";
|
||||
// Note: searchResultsToMcpCsv no longer used in MCP - using structuredContent instead
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("MCP Server", () => {
|
||||
beforeAll(async () => {
|
||||
// LlamaCpp uses node-llama-cpp for local model inference (no HTTP mocking needed)
|
||||
// Use shared singleton to avoid creating multiple instances with separate GPU resources
|
||||
getDefaultLlamaCpp();
|
||||
|
||||
// Set up test config directory
|
||||
const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
testConfigDir = await mkdtemp(configPrefix);
|
||||
process.env.QMD_CONFIG_DIR = testConfigDir;
|
||||
|
||||
// Create YAML config with test collection
|
||||
const testConfig: CollectionConfig = {
|
||||
collections: {
|
||||
docs: {
|
||||
path: "/test/docs",
|
||||
pattern: "**/*.md",
|
||||
context: {
|
||||
"/meetings": "Meeting notes and transcripts"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig));
|
||||
|
||||
testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
|
||||
testDb = new Database(testDbPath);
|
||||
initTestDatabase(testDb);
|
||||
seedTestData(testDb);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
testDb.close();
|
||||
try {
|
||||
require("fs").unlinkSync(testDbPath);
|
||||
} catch {}
|
||||
|
||||
// Clean up test config directory
|
||||
try {
|
||||
const files = await readdir(testConfigDir);
|
||||
for (const file of files) {
|
||||
await unlink(join(testConfigDir, file));
|
||||
}
|
||||
await rmdir(testConfigDir);
|
||||
} catch {}
|
||||
|
||||
delete process.env.QMD_CONFIG_DIR;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tool: qmd_search (BM25)
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd_search tool", () => {
|
||||
test("returns results for matching query", () => {
|
||||
const results = searchFTS(testDb, "readme", 10);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]!.displayPath).toBe("docs/readme.md");
|
||||
});
|
||||
|
||||
test("returns empty for non-matching query", () => {
|
||||
const results = searchFTS(testDb, "xyznonexistent", 10);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("respects limit parameter", () => {
|
||||
const results = searchFTS(testDb, "meeting", 1);
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
// Note: Collection filtering tests removed - collections are now managed in YAML, not DB
|
||||
|
||||
test("formats results as structured content", () => {
|
||||
const results = searchFTS(testDb, "api", 10);
|
||||
const filtered = results.map(r => ({
|
||||
file: r.displayPath,
|
||||
title: r.title,
|
||||
score: Math.round(r.score * 100) / 100,
|
||||
context: getContextForFile(testDb, r.filepath),
|
||||
snippet: extractSnippet(r.body || "", "api", 300, r.chunkPos).snippet,
|
||||
}));
|
||||
// MCP now returns structuredContent with results array
|
||||
expect(filtered.length).toBeGreaterThan(0);
|
||||
expect(filtered[0]).toHaveProperty("file");
|
||||
expect(filtered[0]).toHaveProperty("title");
|
||||
expect(filtered[0]).toHaveProperty("score");
|
||||
expect(filtered[0]).toHaveProperty("snippet");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tool: qmd_vsearch (Vector)
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd_vsearch tool", () => {
|
||||
test("returns results for semantic query", async () => {
|
||||
const results = await searchVec(testDb, "project documentation", DEFAULT_EMBED_MODEL, 10);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
const results = await searchVec(testDb, "documentation", DEFAULT_EMBED_MODEL, 2);
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty when no vector table exists", async () => {
|
||||
const emptyDb = new Database(":memory:");
|
||||
initTestDatabase(emptyDb);
|
||||
emptyDb.exec("DROP TABLE IF EXISTS vectors_vec");
|
||||
|
||||
const results = await searchVec(emptyDb, "test", DEFAULT_EMBED_MODEL, 10);
|
||||
expect(results.length).toBe(0);
|
||||
emptyDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tool: qmd_query (Hybrid)
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd_query tool", () => {
|
||||
test("expands query with variations", async () => {
|
||||
const queries = await expandQuery("api documentation", DEFAULT_QUERY_MODEL, testDb);
|
||||
// Always returns at least the original query, may have more if generation succeeds
|
||||
expect(queries.length).toBeGreaterThanOrEqual(1);
|
||||
expect(queries[0]).toBe("api documentation");
|
||||
}, 30000); // 30s timeout for model loading
|
||||
|
||||
test("performs RRF fusion on multiple result lists", () => {
|
||||
const list1: RankedResult[] = [
|
||||
{ file: "/a", displayPath: "a.md", title: "A", body: "body", score: 1 },
|
||||
{ file: "/b", displayPath: "b.md", title: "B", body: "body", score: 0.8 },
|
||||
];
|
||||
const list2: RankedResult[] = [
|
||||
{ file: "/b", displayPath: "b.md", title: "B", body: "body", score: 1 },
|
||||
{ file: "/c", displayPath: "c.md", title: "C", body: "body", score: 0.9 },
|
||||
];
|
||||
|
||||
const fused = reciprocalRankFusion([list1, list2]);
|
||||
expect(fused.length).toBe(3);
|
||||
// B appears in both lists, should have higher score
|
||||
const bResult = fused.find(r => r.file === "/b");
|
||||
expect(bResult).toBeDefined();
|
||||
});
|
||||
|
||||
test("reranks documents with LLM", async () => {
|
||||
const docs = [
|
||||
{ file: "/test/docs/readme.md", text: "Project readme" },
|
||||
{ file: "/test/docs/api.md", text: "API documentation" },
|
||||
];
|
||||
const reranked = await rerank("readme", docs, DEFAULT_RERANK_MODEL, testDb);
|
||||
expect(reranked.length).toBe(2);
|
||||
expect(reranked[0]!.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("full hybrid search pipeline", async () => {
|
||||
// Simulate full qmd_query flow
|
||||
const query = "meeting notes";
|
||||
const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, testDb);
|
||||
|
||||
const rankedLists: RankedResult[][] = [];
|
||||
for (const q of queries) {
|
||||
const ftsResults = searchFTS(testDb, q, 20);
|
||||
if (ftsResults.length > 0) {
|
||||
rankedLists.push(ftsResults.map(r => ({
|
||||
file: r.filepath,
|
||||
displayPath: r.displayPath,
|
||||
title: r.title,
|
||||
body: r.body || "",
|
||||
score: r.score,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
expect(rankedLists.length).toBeGreaterThan(0);
|
||||
|
||||
const fused = reciprocalRankFusion(rankedLists);
|
||||
expect(fused.length).toBeGreaterThan(0);
|
||||
|
||||
const candidates = fused.slice(0, 10);
|
||||
const reranked = await rerank(
|
||||
query,
|
||||
candidates.map(c => ({ file: c.file, text: c.body })),
|
||||
DEFAULT_RERANK_MODEL,
|
||||
testDb
|
||||
);
|
||||
|
||||
expect(reranked.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tool: qmd_get (Get Document)
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd_get tool", () => {
|
||||
test("retrieves document by display_path", () => {
|
||||
const meta = findDocument(testDb, "readme.md", { includeBody: false });
|
||||
expect("error" in meta).toBe(false);
|
||||
if ("error" in meta) return;
|
||||
const body = getDocumentBody(testDb, meta) ?? "";
|
||||
|
||||
expect(meta.displayPath).toBe("docs/readme.md");
|
||||
expect(body).toContain("Project README");
|
||||
});
|
||||
|
||||
test("retrieves document by filepath", () => {
|
||||
const meta = findDocument(testDb, "/test/docs/api.md", { includeBody: false });
|
||||
expect("error" in meta).toBe(false);
|
||||
if ("error" in meta) return;
|
||||
expect(meta.title).toBe("API Documentation");
|
||||
});
|
||||
|
||||
test("retrieves document by partial path", () => {
|
||||
const result = findDocument(testDb, "api.md", { includeBody: false });
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns not found for missing document", () => {
|
||||
const result = findDocument(testDb, "nonexistent.md", { includeBody: false });
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("suggests similar files when not found", () => {
|
||||
const result = findDocument(testDb, "readm.md", { includeBody: false }); // typo
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.similarFiles.length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("supports line range with :line suffix", () => {
|
||||
const meta = findDocument(testDb, "readme.md:2", { includeBody: false });
|
||||
expect("error" in meta).toBe(false);
|
||||
if ("error" in meta) return;
|
||||
const body = getDocumentBody(testDb, meta, 2, 2) ?? "";
|
||||
const lines = body.split("\n");
|
||||
expect(lines.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("supports fromLine parameter", () => {
|
||||
const meta = findDocument(testDb, "readme.md", { includeBody: false });
|
||||
expect("error" in meta).toBe(false);
|
||||
if ("error" in meta) return;
|
||||
const body = getDocumentBody(testDb, meta, 3) ?? "";
|
||||
expect(body).not.toContain("# Project README");
|
||||
});
|
||||
|
||||
test("supports maxLines parameter", () => {
|
||||
const meta = findDocument(testDb, "api.md", { includeBody: false });
|
||||
expect("error" in meta).toBe(false);
|
||||
if ("error" in meta) return;
|
||||
const body = getDocumentBody(testDb, meta, 1, 3) ?? "";
|
||||
const lines = body.split("\n");
|
||||
expect(lines.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("includes context for documents in context path", () => {
|
||||
const result = findDocument(testDb, "meetings/meeting-2024-01.md", { includeBody: false });
|
||||
expect("error" in result).toBe(false);
|
||||
if ("error" in result) return;
|
||||
expect(result.context).toBe("Meeting notes and transcripts");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tool: qmd_multi_get (Multi Get)
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd_multi_get tool", () => {
|
||||
test("retrieves multiple documents by glob pattern", () => {
|
||||
const { docs, errors } = findDocuments(testDb, "meetings/*.md", { includeBody: true });
|
||||
expect(errors.length).toBe(0);
|
||||
expect(docs.length).toBe(2);
|
||||
const paths = docs.map(d => d.doc.displayPath);
|
||||
expect(paths).toContain("docs/meetings/meeting-2024-01.md");
|
||||
expect(paths).toContain("docs/meetings/meeting-2024-02.md");
|
||||
});
|
||||
|
||||
test("retrieves documents by comma-separated list", () => {
|
||||
const { docs, errors } = findDocuments(testDb, "readme.md, api.md", { includeBody: true });
|
||||
expect(errors.length).toBe(0);
|
||||
expect(docs.length).toBe(2);
|
||||
});
|
||||
|
||||
test("returns errors for missing files in comma list", () => {
|
||||
const { docs, errors } = findDocuments(testDb, "readme.md, nonexistent.md", { includeBody: true });
|
||||
expect(docs.length).toBe(1);
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toContain("not found");
|
||||
});
|
||||
|
||||
test("skips files larger than maxBytes", () => {
|
||||
const { docs } = findDocuments(testDb, "*.md", { includeBody: true, maxBytes: 1000 }); // 1KB limit
|
||||
const large = docs.find(d => d.doc.displayPath === "docs/large-file.md");
|
||||
expect(large).toBeDefined();
|
||||
expect(large?.skipped).toBe(true);
|
||||
if (large?.skipped) expect(large.skipReason).toContain("too large");
|
||||
});
|
||||
|
||||
test("respects maxLines parameter", () => {
|
||||
const { docs } = findDocuments(testDb, "readme.md", { includeBody: true, maxBytes: DEFAULT_MULTI_GET_MAX_BYTES });
|
||||
expect(docs.length).toBe(1);
|
||||
const d = docs[0]!;
|
||||
expect(d.skipped).toBe(false);
|
||||
if (d.skipped) return;
|
||||
if (!("body" in d.doc)) {
|
||||
throw new Error("Expected body to be included in findDocuments result");
|
||||
}
|
||||
const lines = (d.doc.body || "").split("\n").slice(0, 2);
|
||||
expect(lines.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns error for non-matching glob", () => {
|
||||
const { docs, errors } = findDocuments(testDb, "nonexistent/*.md", { includeBody: true });
|
||||
expect(docs.length).toBe(0);
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toContain("No files matched");
|
||||
});
|
||||
|
||||
test("includes context in results", () => {
|
||||
const { docs } = findDocuments(testDb, "meetings/meeting-2024-01.md", { includeBody: true });
|
||||
expect(docs.length).toBe(1);
|
||||
const d = docs[0]!;
|
||||
expect(d.skipped).toBe(false);
|
||||
if (d.skipped) return;
|
||||
if (!("context" in d.doc)) {
|
||||
throw new Error("Expected context to be present on document result");
|
||||
}
|
||||
expect(d.doc.context).toBe("Meeting notes and transcripts");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tool: qmd_status
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd_status tool", () => {
|
||||
test("returns index status", () => {
|
||||
const status = getStatus(testDb);
|
||||
expect(status.totalDocuments).toBe(5);
|
||||
expect(status.hasVectorIndex).toBe(true);
|
||||
expect(status.collections.length).toBe(1);
|
||||
expect(status.collections[0]!.path).toBe("/test/docs");
|
||||
});
|
||||
|
||||
test("shows documents needing embedding", () => {
|
||||
const status = getStatus(testDb);
|
||||
// large-file.md doesn't have embeddings
|
||||
expect(status.needsEmbedding).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Resource: qmd://{path}
|
||||
// ===========================================================================
|
||||
|
||||
describe("qmd:// resource", () => {
|
||||
test("lists all documents", () => {
|
||||
const docs = testDb.prepare(`
|
||||
SELECT path as display_path, title
|
||||
FROM documents
|
||||
WHERE active = 1
|
||||
ORDER BY modified_at DESC
|
||||
LIMIT 1000
|
||||
`).all() as { display_path: string; title: string }[];
|
||||
|
||||
expect(docs.length).toBe(5);
|
||||
expect(docs.map(d => d.display_path)).toContain("readme.md");
|
||||
});
|
||||
|
||||
test("reads document by display_path", () => {
|
||||
const path = "readme.md";
|
||||
const doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path = ? AND d.active = 1
|
||||
`).get(path) as { filepath: string; display_path: string; body: string } | null;
|
||||
|
||||
expect(doc).not.toBeNull();
|
||||
expect(doc?.body).toContain("Project README");
|
||||
});
|
||||
|
||||
test("reads document by URL-encoded path", () => {
|
||||
// Simulate URL encoding that MCP clients may send
|
||||
const encodedPath = "meetings%2Fmeeting-2024-01.md";
|
||||
const decodedPath = decodeURIComponent(encodedPath);
|
||||
|
||||
const doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path = ? AND d.active = 1
|
||||
`).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
|
||||
|
||||
expect(doc).not.toBeNull();
|
||||
expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
|
||||
});
|
||||
|
||||
test("reads document by suffix match", () => {
|
||||
const path = "meeting-2024-01.md"; // without meetings/ prefix
|
||||
let doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path = ? AND d.active = 1
|
||||
`).get(path) as { filepath: string; display_path: string; body: string } | null;
|
||||
|
||||
if (!doc) {
|
||||
doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path LIKE ? AND d.active = 1
|
||||
LIMIT 1
|
||||
`).get(`%${path}`) as { filepath: string; display_path: string; body: string } | null;
|
||||
}
|
||||
|
||||
expect(doc).not.toBeNull();
|
||||
expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
|
||||
});
|
||||
|
||||
test("returns not found for missing document", () => {
|
||||
const path = "nonexistent.md";
|
||||
const doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path = ? AND d.active = 1
|
||||
`).get(path) as { filepath: string; display_path: string; body: string } | null;
|
||||
|
||||
expect(doc).toBeNull();
|
||||
});
|
||||
|
||||
test("includes context in document body", () => {
|
||||
const path = "meetings/meeting-2024-01.md";
|
||||
const doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path = ? AND d.active = 1
|
||||
`).get(path) as { filepath: string; display_path: string; body: string } | null;
|
||||
|
||||
expect(doc).not.toBeNull();
|
||||
const context = getContextForFile(testDb, doc!.filepath);
|
||||
expect(context).toBe("Meeting notes and transcripts");
|
||||
|
||||
// Verify context would be prepended
|
||||
let text = doc!.body;
|
||||
if (context) {
|
||||
text = `<!-- Context: ${context} -->\n\n` + text;
|
||||
}
|
||||
expect(text).toContain("<!-- Context: Meeting notes and transcripts -->");
|
||||
});
|
||||
|
||||
test("handles URL-encoded special characters", () => {
|
||||
// Test various URL encodings
|
||||
const testCases = [
|
||||
{ encoded: "readme.md", decoded: "readme.md" },
|
||||
{ encoded: "meetings%2Fmeeting-2024-01.md", decoded: "meetings/meeting-2024-01.md" },
|
||||
{ encoded: "api.md%3A10", decoded: "api.md:10" }, // with line number
|
||||
];
|
||||
|
||||
for (const { encoded, decoded } of testCases) {
|
||||
expect(decodeURIComponent(encoded)).toBe(decoded);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles double-encoded URLs", () => {
|
||||
// Some clients may double-encode
|
||||
const doubleEncoded = "meetings%252Fmeeting-2024-01.md";
|
||||
const singleDecoded = decodeURIComponent(doubleEncoded);
|
||||
expect(singleDecoded).toBe("meetings%2Fmeeting-2024-01.md");
|
||||
|
||||
const fullyDecoded = decodeURIComponent(singleDecoded);
|
||||
expect(fullyDecoded).toBe("meetings/meeting-2024-01.md");
|
||||
});
|
||||
|
||||
test("handles URL-encoded paths with spaces", () => {
|
||||
// Add a document with spaces in the path
|
||||
const now = new Date().toISOString();
|
||||
const body = "# Podcast Episode\n\nInterview content here.";
|
||||
const hash = "hash_spaces";
|
||||
const path = "External Podcast/2023 April - Interview.md";
|
||||
|
||||
// Insert content first
|
||||
testDb.prepare(`
|
||||
INSERT OR IGNORE INTO content (hash, doc, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(hash, body, now);
|
||||
|
||||
// Then insert document metadata
|
||||
testDb.prepare(`
|
||||
INSERT INTO documents (collection, path, title, hash, created_at, modified_at, active)
|
||||
VALUES ('docs', ?, ?, ?, ?, ?, 1)
|
||||
`).run(path, "Podcast Episode", hash, now, now);
|
||||
|
||||
// Simulate URL-encoded path from MCP client
|
||||
const encodedPath = "External%20Podcast%2F2023%20April%20-%20Interview.md";
|
||||
const decodedPath = decodeURIComponent(encodedPath);
|
||||
|
||||
expect(decodedPath).toBe("External Podcast/2023 April - Interview.md");
|
||||
|
||||
const doc = testDb.prepare(`
|
||||
SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.path = ? AND d.active = 1
|
||||
`).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
|
||||
|
||||
expect(doc).not.toBeNull();
|
||||
expect(doc?.display_path).toBe("External Podcast/2023 April - Interview.md");
|
||||
expect(doc?.body).toContain("Podcast Episode");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Prompt: query
|
||||
// ===========================================================================
|
||||
|
||||
describe("query prompt", () => {
|
||||
test("returns usage guide", () => {
|
||||
// The prompt content is static, just verify the structure
|
||||
const promptContent = `# QMD - Quick Markdown Search
|
||||
|
||||
QMD is your on-device search engine for markdown knowledge bases.`;
|
||||
|
||||
expect(promptContent).toContain("QMD");
|
||||
expect(promptContent).toContain("search");
|
||||
});
|
||||
|
||||
test("describes all available tools", () => {
|
||||
const toolNames = [
|
||||
"qmd_search",
|
||||
"qmd_vsearch",
|
||||
"qmd_query",
|
||||
"qmd_get",
|
||||
"qmd_multi_get",
|
||||
"qmd_status",
|
||||
];
|
||||
|
||||
// Verify these are documented in the prompt
|
||||
const promptGuide = `
|
||||
### 1. qmd_search (Fast keyword search)
|
||||
### 2. qmd_vsearch (Semantic search)
|
||||
### 3. qmd_query (Hybrid search - highest quality)
|
||||
### 4. qmd_get (Retrieve document)
|
||||
### 5. qmd_multi_get (Retrieve multiple documents)
|
||||
### 6. qmd_status (Index info)
|
||||
`;
|
||||
|
||||
for (const tool of toolNames) {
|
||||
expect(promptGuide).toContain(tool);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Edge Cases
|
||||
// ===========================================================================
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles empty query", () => {
|
||||
const results = searchFTS(testDb, "", 10);
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("handles special characters in query", () => {
|
||||
const results = searchFTS(testDb, "project's", 10);
|
||||
// Should not throw
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles unicode in query", () => {
|
||||
const results = searchFTS(testDb, "文档", 10);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles very long query", () => {
|
||||
const longQuery = "documentation ".repeat(100);
|
||||
const results = searchFTS(testDb, longQuery, 10);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("handles query with only stopwords", () => {
|
||||
const results = searchFTS(testDb, "the and or", 10);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test("extracts snippet around matching text", () => {
|
||||
const body = "Line 1\nLine 2\nThis is the important line with the keyword\nLine 4\nLine 5";
|
||||
const { line, snippet } = extractSnippet(body, "keyword", 200);
|
||||
expect(snippet).toContain("keyword");
|
||||
expect(line).toBe(3);
|
||||
});
|
||||
|
||||
test("handles snippet extraction with chunkPos", () => {
|
||||
const body = "A".repeat(1000) + "KEYWORD" + "B".repeat(1000);
|
||||
const chunkPos = 1000; // Position of KEYWORD
|
||||
const { snippet } = extractSnippet(body, "keyword", 200, chunkPos);
|
||||
expect(snippet).toContain("KEYWORD");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// MCP Spec Compliance
|
||||
// ===========================================================================
|
||||
|
||||
describe("MCP spec compliance", () => {
|
||||
test("encodeQmdPath preserves slashes but encodes special chars", () => {
|
||||
// Helper function behavior (tested indirectly through resource URIs)
|
||||
const path = "External Podcast/2023 April - Interview.md";
|
||||
const segments = path.split('/').map(s => encodeURIComponent(s)).join('/');
|
||||
expect(segments).toBe("External%20Podcast/2023%20April%20-%20Interview.md");
|
||||
expect(segments).toContain("/"); // Slashes preserved
|
||||
expect(segments).toContain("%20"); // Spaces encoded
|
||||
});
|
||||
|
||||
test("search results have correct structure for structuredContent", () => {
|
||||
const results = searchFTS(testDb, "readme", 5);
|
||||
const structured = results.map(r => ({
|
||||
file: r.displayPath,
|
||||
title: r.title,
|
||||
score: Math.round(r.score * 100) / 100,
|
||||
context: getContextForFile(testDb, r.filepath),
|
||||
snippet: extractSnippet(r.body || "", "readme", 300, r.chunkPos).snippet,
|
||||
}));
|
||||
|
||||
expect(structured.length).toBeGreaterThan(0);
|
||||
const item = structured[0]!;
|
||||
expect(typeof item.file).toBe("string");
|
||||
expect(typeof item.title).toBe("string");
|
||||
expect(typeof item.score).toBe("number");
|
||||
expect(item.score).toBeGreaterThanOrEqual(0);
|
||||
expect(item.score).toBeLessThanOrEqual(1);
|
||||
expect(typeof item.snippet).toBe("string");
|
||||
});
|
||||
|
||||
test("error responses should include isError flag", () => {
|
||||
// Simulate what MCP server returns for errors
|
||||
const errorResponse = {
|
||||
content: [{ type: "text", text: "Collection not found: nonexistent" }],
|
||||
isError: true,
|
||||
};
|
||||
expect(errorResponse.isError).toBe(true);
|
||||
expect(errorResponse.content[0]!.type).toBe("text");
|
||||
});
|
||||
|
||||
test("embedded resources include name and title", () => {
|
||||
// Simulate what qmd_get returns
|
||||
const meta = findDocument(testDb, "readme.md", { includeBody: false });
|
||||
expect("error" in meta).toBe(false);
|
||||
if ("error" in meta) return;
|
||||
const body = getDocumentBody(testDb, meta) ?? "";
|
||||
const resource = {
|
||||
uri: `qmd://${meta.displayPath}`,
|
||||
name: meta.displayPath,
|
||||
title: meta.title,
|
||||
mimeType: "text/markdown",
|
||||
text: body,
|
||||
};
|
||||
expect(resource.name).toBe("docs/readme.md");
|
||||
expect(resource.title).toBe("Project README");
|
||||
expect(resource.mimeType).toBe("text/markdown");
|
||||
});
|
||||
|
||||
test("status response includes structuredContent", () => {
|
||||
const status = getStatus(testDb);
|
||||
// Verify structure matches StatusResult type
|
||||
expect(typeof status.totalDocuments).toBe("number");
|
||||
expect(typeof status.needsEmbedding).toBe("number");
|
||||
expect(typeof status.hasVectorIndex).toBe("boolean");
|
||||
expect(Array.isArray(status.collections)).toBe(true);
|
||||
if (status.collections.length > 0) {
|
||||
const col = status.collections[0]!;
|
||||
expect(typeof col.name).toBe("string"); // Collections now use names, not IDs
|
||||
expect(typeof col.path).toBe("string");
|
||||
expect(typeof col.pattern).toBe("string");
|
||||
expect(typeof col.documents).toBe("number");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,626 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* QMD MCP Server - Model Context Protocol server for QMD
|
||||
*
|
||||
* Exposes QMD search and document retrieval as MCP tools and resources.
|
||||
* Documents are accessible via qmd:// URIs.
|
||||
*
|
||||
* Follows MCP spec 2025-06-18 for proper response types.
|
||||
*/
|
||||
|
||||
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createStore,
|
||||
reciprocalRankFusion,
|
||||
extractSnippet,
|
||||
DEFAULT_EMBED_MODEL,
|
||||
DEFAULT_QUERY_MODEL,
|
||||
DEFAULT_RERANK_MODEL,
|
||||
DEFAULT_MULTI_GET_MAX_BYTES,
|
||||
} from "./store.js";
|
||||
import type { RankedResult } from "./store.js";
|
||||
|
||||
// =============================================================================
|
||||
// Types for structured content
|
||||
// =============================================================================
|
||||
|
||||
type SearchResultItem = {
|
||||
docid: string; // Short docid (#abc123) for quick reference
|
||||
file: string;
|
||||
title: string;
|
||||
score: number;
|
||||
context: string | null;
|
||||
snippet: string;
|
||||
};
|
||||
|
||||
type StatusResult = {
|
||||
totalDocuments: number;
|
||||
needsEmbedding: number;
|
||||
hasVectorIndex: boolean;
|
||||
collections: {
|
||||
name: string;
|
||||
path: string;
|
||||
pattern: string;
|
||||
documents: number;
|
||||
lastUpdated: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Encode a path for use in qmd:// URIs.
|
||||
* Encodes special characters but preserves forward slashes for readability.
|
||||
*/
|
||||
function encodeQmdPath(path: string): string {
|
||||
// Encode each path segment separately to preserve slashes
|
||||
return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results as human-readable text summary
|
||||
*/
|
||||
function formatSearchSummary(results: SearchResultItem[], query: string): string {
|
||||
if (results.length === 0) {
|
||||
return `No results found for "${query}"`;
|
||||
}
|
||||
const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
|
||||
for (const r of results) {
|
||||
lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add line numbers to text content.
|
||||
* Each line becomes: "{lineNum}: {content}"
|
||||
*/
|
||||
function addLineNumbers(text: string, startLine: number = 1): string {
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MCP Server
|
||||
// =============================================================================
|
||||
|
||||
export async function startMcpServer(): Promise<void> {
|
||||
// Open database once at startup - keep it open for the lifetime of the server
|
||||
const store = createStore();
|
||||
|
||||
const server = new McpServer({
|
||||
name: "qmd",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: qmd://{path} - read-only access to documents by path
|
||||
// Note: No list() - documents are discovered via search tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerResource(
|
||||
"document",
|
||||
new ResourceTemplate("qmd://{+path}", { list: undefined }),
|
||||
{
|
||||
title: "QMD Document",
|
||||
description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
async (uri, { path }) => {
|
||||
// Decode URL-encoded path (MCP clients send encoded URIs)
|
||||
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
|
||||
const decodedPath = decodeURIComponent(pathStr);
|
||||
|
||||
// Parse virtual path: collection/relative/path
|
||||
const parts = decodedPath.split('/');
|
||||
const collection = parts[0] || '';
|
||||
const relativePath = parts.slice(1).join('/');
|
||||
|
||||
// Find document by collection and path, join with content table
|
||||
let doc = store.db.prepare(`
|
||||
SELECT d.collection, d.path, d.title, c.doc as body
|
||||
FROM documents d
|
||||
JOIN content c ON c.hash = d.hash
|
||||
WHERE d.collection = ? AND d.path = ? AND d.active = 1
|
||||
`).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
|
||||
|
||||
// Try suffix match if exact match fails
|
||||
if (!doc) {
|
||||
doc = store.db.prepare(`
|
||||
SELECT d.collection, d.path, d.title, c.doc as body
|
||||
FROM documents d
|
||||
JOIN content c ON c.hash = d.hash
|
||||
WHERE d.path LIKE ? AND d.active = 1
|
||||
LIMIT 1
|
||||
`).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
|
||||
}
|
||||
|
||||
// Construct virtual path for context lookup
|
||||
const virtualPath = `qmd://${doc.collection}/${doc.path}`;
|
||||
const context = store.getContextForFile(virtualPath);
|
||||
|
||||
let text = addLineNumbers(doc.body); // Default to line numbers
|
||||
if (context) {
|
||||
text = `<!-- Context: ${context} -->\n\n` + text;
|
||||
}
|
||||
|
||||
const displayName = `${doc.collection}/${doc.path}`;
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
name: displayName,
|
||||
title: doc.title || doc.path,
|
||||
mimeType: "text/markdown",
|
||||
text,
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt: query guide
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerPrompt(
|
||||
"query",
|
||||
{
|
||||
title: "QMD Query Guide",
|
||||
description: "How to effectively search your knowledge base with QMD",
|
||||
},
|
||||
() => ({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `# QMD - Quick Markdown Search
|
||||
|
||||
QMD is your on-device search engine for markdown knowledge bases. Use it to find information across your notes, documents, and meeting transcripts.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### 1. search (Fast keyword search)
|
||||
Best for: Finding documents with specific keywords or phrases.
|
||||
- Uses BM25 full-text search
|
||||
- Fast, no LLM required
|
||||
- Good for exact matches
|
||||
- Use \`collection\` parameter to filter to a specific collection
|
||||
|
||||
### 2. vsearch (Semantic search)
|
||||
Best for: Finding conceptually related content even without exact keyword matches.
|
||||
- Uses vector embeddings
|
||||
- Understands meaning and context
|
||||
- Good for "how do I..." or conceptual queries
|
||||
- Use \`collection\` parameter to filter to a specific collection
|
||||
|
||||
### 3. query (Hybrid search - highest quality)
|
||||
Best for: Important searches where you want the best results.
|
||||
- Combines keyword + semantic search
|
||||
- Expands your query with variations
|
||||
- Re-ranks results with LLM
|
||||
- Slower but most accurate
|
||||
- Use \`collection\` parameter to filter to a specific collection
|
||||
|
||||
### 4. get (Retrieve document)
|
||||
Best for: Getting the full content of a single document you found.
|
||||
- Use the file path from search results
|
||||
- Supports line ranges: \`file.md:100\` or fromLine/maxLines parameters
|
||||
- Suggests similar files if not found
|
||||
|
||||
### 5. multi_get (Retrieve multiple documents)
|
||||
Best for: Getting content from multiple files at once.
|
||||
- Use glob patterns: \`journals/2025-05*.md\`
|
||||
- Or comma-separated: \`file1.md, file2.md\`
|
||||
- Skips files over maxBytes (default 10KB) - use get for large files
|
||||
|
||||
### 6. status (Index info)
|
||||
Shows collection info, document counts, and embedding status.
|
||||
|
||||
## Resources
|
||||
|
||||
You can also access documents directly via the \`qmd://\` URI scheme:
|
||||
- List all documents: \`resources/list\`
|
||||
- Read a document: \`resources/read\` with uri \`qmd://path/to/file.md\`
|
||||
|
||||
## Search Strategy
|
||||
|
||||
1. **Start with search** for quick keyword lookups
|
||||
2. **Use vsearch** when keywords aren't working or for conceptual queries
|
||||
3. **Use query** for important searches or when you need high confidence
|
||||
4. **Use get** to retrieve a single full document
|
||||
5. **Use multi_get** to batch retrieve multiple related files
|
||||
|
||||
## Tips
|
||||
|
||||
- Use \`minScore: 0.5\` to filter low-relevance results
|
||||
- Use \`collection: "notes"\` to search only in a specific collection
|
||||
- Check the "Context" field - it describes what kind of content the file contains
|
||||
- File paths are relative to their collection (e.g., \`pages/meeting.md\`)
|
||||
- For glob patterns, match on display_path (e.g., \`journals/2025-*.md\`)`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: qmd_search (BM25 full-text)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
"search",
|
||||
{
|
||||
title: "Search (BM25)",
|
||||
description: "Fast keyword-based full-text search using BM25. Best for finding documents with specific words or phrases.",
|
||||
inputSchema: {
|
||||
query: z.string().describe("Search query - keywords or phrases to find"),
|
||||
limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
|
||||
minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
|
||||
collection: z.string().optional().describe("Filter to a specific collection by name"),
|
||||
},
|
||||
},
|
||||
async ({ query, limit, minScore, collection }) => {
|
||||
// Note: Collection filtering is now done post-search since collections are managed in YAML
|
||||
const results = store.searchFTS(query, limit || 10)
|
||||
.filter(r => !collection || r.collectionName === collection);
|
||||
const filtered: SearchResultItem[] = results
|
||||
.filter(r => r.score >= (minScore || 0))
|
||||
.map(r => {
|
||||
const { line, snippet } = extractSnippet(r.body || "", query, 300, r.chunkPos);
|
||||
return {
|
||||
docid: `#${r.docid}`,
|
||||
file: r.displayPath,
|
||||
title: r.title,
|
||||
score: Math.round(r.score * 100) / 100,
|
||||
context: store.getContextForFile(r.filepath),
|
||||
snippet: addLineNumbers(snippet, line), // Default to line numbers
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
|
||||
structuredContent: { results: filtered },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: qmd_vsearch (Vector semantic search)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
"vsearch",
|
||||
{
|
||||
title: "Vector Search (Semantic)",
|
||||
description: "Semantic similarity search using vector embeddings. Finds conceptually related content even without exact keyword matches. Requires embeddings (run 'qmd embed' first).",
|
||||
inputSchema: {
|
||||
query: z.string().describe("Natural language query - describe what you're looking for"),
|
||||
limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
|
||||
minScore: z.number().optional().default(0.3).describe("Minimum relevance score 0-1 (default: 0.3)"),
|
||||
collection: z.string().optional().describe("Filter to a specific collection by name"),
|
||||
},
|
||||
},
|
||||
async ({ query, limit, minScore, collection }) => {
|
||||
const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
||||
if (!tableExists) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Vector index not found. Run 'qmd embed' first to create embeddings." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Expand query
|
||||
const queries = await store.expandQuery(query, DEFAULT_QUERY_MODEL);
|
||||
|
||||
// Collect results (filter by collection after search)
|
||||
const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number; docid: string }>();
|
||||
for (const q of queries) {
|
||||
const vecResults = await store.searchVec(q, DEFAULT_EMBED_MODEL, limit || 10)
|
||||
.then(results => results.filter(r => !collection || r.collectionName === collection));
|
||||
for (const r of vecResults) {
|
||||
const existing = allResults.get(r.filepath);
|
||||
if (!existing || r.score > existing.score) {
|
||||
allResults.set(r.filepath, { file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score, docid: r.docid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filtered: SearchResultItem[] = Array.from(allResults.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit || 10)
|
||||
.filter(r => r.score >= (minScore || 0.3))
|
||||
.map(r => {
|
||||
const { line, snippet } = extractSnippet(r.body || "", query, 300);
|
||||
return {
|
||||
docid: `#${r.docid}`,
|
||||
file: r.displayPath,
|
||||
title: r.title,
|
||||
score: Math.round(r.score * 100) / 100,
|
||||
context: store.getContextForFile(r.file),
|
||||
snippet: addLineNumbers(snippet, line), // Default to line numbers
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
|
||||
structuredContent: { results: filtered },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: qmd_query (Hybrid with reranking)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
"query",
|
||||
{
|
||||
title: "Hybrid Query (Best Quality)",
|
||||
description: "Highest quality search combining BM25 + vector + query expansion + LLM reranking. Slower but most accurate. Use for important searches.",
|
||||
inputSchema: {
|
||||
query: z.string().describe("Natural language query - describe what you're looking for"),
|
||||
limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
|
||||
minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
|
||||
collection: z.string().optional().describe("Filter to a specific collection by name"),
|
||||
},
|
||||
},
|
||||
async ({ query, limit, minScore, collection }) => {
|
||||
// Expand query
|
||||
const queries = await store.expandQuery(query, DEFAULT_QUERY_MODEL);
|
||||
|
||||
// Collect ranked lists (filter by collection after search)
|
||||
const rankedLists: RankedResult[][] = [];
|
||||
const docidMap = new Map<string, string>(); // filepath -> docid
|
||||
const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
||||
|
||||
for (const q of queries) {
|
||||
const ftsResults = store.searchFTS(q, 20)
|
||||
.filter(r => !collection || r.collectionName === collection);
|
||||
if (ftsResults.length > 0) {
|
||||
for (const r of ftsResults) docidMap.set(r.filepath, r.docid);
|
||||
rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
|
||||
}
|
||||
if (hasVectors) {
|
||||
const vecResults = await store.searchVec(q, DEFAULT_EMBED_MODEL, 20)
|
||||
.then(results => results.filter(r => !collection || r.collectionName === collection));
|
||||
if (vecResults.length > 0) {
|
||||
for (const r of vecResults) docidMap.set(r.filepath, r.docid);
|
||||
rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RRF fusion
|
||||
const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
|
||||
const fused = reciprocalRankFusion(rankedLists, weights);
|
||||
const candidates = fused.slice(0, 30);
|
||||
|
||||
// Rerank
|
||||
const reranked = await store.rerank(
|
||||
query,
|
||||
candidates.map(c => ({ file: c.file, text: c.body })),
|
||||
DEFAULT_RERANK_MODEL
|
||||
);
|
||||
|
||||
// Blend scores
|
||||
const candidateMap = new Map(candidates.map(c => [c.file, { displayPath: c.displayPath, title: c.title, body: c.body }]));
|
||||
const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
|
||||
|
||||
const filtered: SearchResultItem[] = reranked.map(r => {
|
||||
const rrfRank = rrfRankMap.get(r.file) || candidates.length;
|
||||
let rrfWeight: number;
|
||||
if (rrfRank <= 3) rrfWeight = 0.75;
|
||||
else if (rrfRank <= 10) rrfWeight = 0.60;
|
||||
else rrfWeight = 0.40;
|
||||
const rrfScore = 1 / rrfRank;
|
||||
const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
|
||||
const candidate = candidateMap.get(r.file);
|
||||
const { line, snippet } = extractSnippet(candidate?.body || "", query, 300);
|
||||
return {
|
||||
docid: `#${docidMap.get(r.file) || ""}`,
|
||||
file: candidate?.displayPath || "",
|
||||
title: candidate?.title || "",
|
||||
score: Math.round(blendedScore * 100) / 100,
|
||||
context: store.getContextForFile(r.file),
|
||||
snippet: addLineNumbers(snippet, line), // Default to line numbers
|
||||
};
|
||||
}).filter(r => r.score >= (minScore || 0)).slice(0, limit || 10);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
|
||||
structuredContent: { results: filtered },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: qmd_get (Retrieve document)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
"get",
|
||||
{
|
||||
title: "Get Document",
|
||||
description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
|
||||
inputSchema: {
|
||||
file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"),
|
||||
fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
|
||||
maxLines: z.number().optional().describe("Maximum number of lines to return"),
|
||||
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
|
||||
},
|
||||
},
|
||||
async ({ file, fromLine, maxLines, lineNumbers }) => {
|
||||
// Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
|
||||
let parsedFromLine = fromLine;
|
||||
let lookup = file;
|
||||
const colonMatch = lookup.match(/:(\d+)$/);
|
||||
if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
|
||||
parsedFromLine = parseInt(colonMatch[1], 10);
|
||||
lookup = lookup.slice(0, -colonMatch[0].length);
|
||||
}
|
||||
|
||||
const result = store.findDocument(lookup, { includeBody: false });
|
||||
|
||||
if ("error" in result) {
|
||||
let msg = `Document not found: ${file}`;
|
||||
if (result.similarFiles.length > 0) {
|
||||
msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: msg }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
|
||||
let text = body;
|
||||
if (lineNumbers) {
|
||||
const startLine = parsedFromLine || 1;
|
||||
text = addLineNumbers(text, startLine);
|
||||
}
|
||||
if (result.context) {
|
||||
text = `<!-- Context: ${result.context} -->\n\n` + text;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: `qmd://${encodeQmdPath(result.displayPath)}`,
|
||||
name: result.displayPath,
|
||||
title: result.title,
|
||||
mimeType: "text/markdown",
|
||||
text,
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: qmd_multi_get (Retrieve multiple documents)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
"multi_get",
|
||||
{
|
||||
title: "Multi-Get Documents",
|
||||
description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
|
||||
inputSchema: {
|
||||
pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
|
||||
maxLines: z.number().optional().describe("Maximum lines per file"),
|
||||
maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
|
||||
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
|
||||
},
|
||||
},
|
||||
async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
|
||||
const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
|
||||
|
||||
if (docs.length === 0 && errors.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; name: string; title?: string; mimeType: string; text: string } })[] = [];
|
||||
|
||||
if (errors.length > 0) {
|
||||
content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
|
||||
}
|
||||
|
||||
for (const result of docs) {
|
||||
if (result.skipped) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = result.doc.body || "";
|
||||
if (maxLines !== undefined) {
|
||||
const lines = text.split("\n");
|
||||
text = lines.slice(0, maxLines).join("\n");
|
||||
if (lines.length > maxLines) {
|
||||
text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
|
||||
}
|
||||
}
|
||||
if (lineNumbers) {
|
||||
text = addLineNumbers(text);
|
||||
}
|
||||
if (result.doc.context) {
|
||||
text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`,
|
||||
name: result.doc.displayPath,
|
||||
title: result.doc.title,
|
||||
mimeType: "text/markdown",
|
||||
text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { content };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool: qmd_status (Index status)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
"status",
|
||||
{
|
||||
title: "Index Status",
|
||||
description: "Show the status of the QMD index: collections, document counts, and health information.",
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const status: StatusResult = store.getStatus();
|
||||
|
||||
const summary = [
|
||||
`QMD Index Status:`,
|
||||
` Total documents: ${status.totalDocuments}`,
|
||||
` Needs embedding: ${status.needsEmbedding}`,
|
||||
` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
|
||||
` Collections: ${status.collections.length}`,
|
||||
];
|
||||
|
||||
for (const col of status.collections) {
|
||||
summary.push(` - ${col.path} (${col.documents} docs)`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: summary.join('\n') }],
|
||||
structuredContent: status,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connect via stdio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// Note: Database stays open - it will be closed when the process exits
|
||||
}
|
||||
|
||||
// Run if this is the main module
|
||||
if (import.meta.main) {
|
||||
startMcpServer().catch(console.error);
|
||||
}
|
||||
2699
tools/qmd/src/qmd.ts
2699
tools/qmd/src/qmd.ts
File diff suppressed because it is too large
Load Diff
@@ -1,395 +0,0 @@
|
||||
/**
|
||||
* store-paths.test.ts - Comprehensive unit tests for Windows path support
|
||||
*
|
||||
* Tests all path-related utility functions for cross-platform compatibility:
|
||||
* - isAbsolutePath() - Unix, Windows (C:\, C:/), and Git Bash (/c/) paths
|
||||
* - normalizePathSeparators() - backslash to forward slash conversion
|
||||
* - getRelativePathFromPrefix() - relative path extraction
|
||||
* - resolve() - path resolution with Unix and Windows paths
|
||||
*
|
||||
* Run with: bun test store-paths.test.ts
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
isAbsolutePath,
|
||||
normalizePathSeparators,
|
||||
getRelativePathFromPrefix,
|
||||
resolve,
|
||||
} from "./store.js";
|
||||
|
||||
// =============================================================================
|
||||
// Test Utilities
|
||||
// =============================================================================
|
||||
|
||||
let originalPWD: string | undefined;
|
||||
let originalProcessCwd: () => string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalPWD = Bun.env.PWD;
|
||||
originalProcessCwd = process.cwd;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
if (originalPWD !== undefined) {
|
||||
Bun.env.PWD = originalPWD;
|
||||
} else {
|
||||
delete Bun.env.PWD;
|
||||
}
|
||||
process.cwd = originalProcessCwd;
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock the current working directory for testing.
|
||||
* Sets both Bun.env.PWD and process.cwd() to simulate different environments.
|
||||
*/
|
||||
function mockPWD(path: string): void {
|
||||
Bun.env.PWD = path;
|
||||
process.cwd = () => path;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Path Utilities - Cross-platform Support
|
||||
// =============================================================================
|
||||
|
||||
describe("Path utilities - Cross-platform support", () => {
|
||||
|
||||
// ===========================================================================
|
||||
// isAbsolutePath
|
||||
// ===========================================================================
|
||||
|
||||
describe("isAbsolutePath", () => {
|
||||
test("Unix absolute paths", () => {
|
||||
expect(isAbsolutePath("/path/to/file")).toBe(true);
|
||||
expect(isAbsolutePath("/")).toBe(true);
|
||||
expect(isAbsolutePath("/home/user/documents")).toBe(true);
|
||||
expect(isAbsolutePath("/usr/local/bin")).toBe(true);
|
||||
});
|
||||
|
||||
test("Unix relative paths", () => {
|
||||
expect(isAbsolutePath("path/to/file")).toBe(false);
|
||||
expect(isAbsolutePath("./path/to/file")).toBe(false);
|
||||
expect(isAbsolutePath("../path/to/file")).toBe(false);
|
||||
expect(isAbsolutePath("./file")).toBe(false);
|
||||
expect(isAbsolutePath("../file")).toBe(false);
|
||||
expect(isAbsolutePath("file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("Windows absolute paths (native) - forward slash", () => {
|
||||
expect(isAbsolutePath("C:/path/to/file")).toBe(true);
|
||||
expect(isAbsolutePath("C:/")).toBe(true);
|
||||
expect(isAbsolutePath("D:/Users/Documents")).toBe(true);
|
||||
expect(isAbsolutePath("Z:/")).toBe(true);
|
||||
expect(isAbsolutePath("c:/lowercase")).toBe(true);
|
||||
});
|
||||
|
||||
test("Windows absolute paths (native) - backslash", () => {
|
||||
expect(isAbsolutePath("C:\\path\\to\\file")).toBe(true);
|
||||
expect(isAbsolutePath("C:\\")).toBe(true);
|
||||
expect(isAbsolutePath("D:\\Users\\Documents")).toBe(true);
|
||||
expect(isAbsolutePath("Z:\\")).toBe(true);
|
||||
expect(isAbsolutePath("c:\\lowercase")).toBe(true);
|
||||
});
|
||||
|
||||
test("Windows relative paths", () => {
|
||||
expect(isAbsolutePath("path\\to\\file")).toBe(false);
|
||||
expect(isAbsolutePath(".\\path\\to\\file")).toBe(false);
|
||||
expect(isAbsolutePath("..\\path\\to\\file")).toBe(false);
|
||||
expect(isAbsolutePath(".\\file")).toBe(false);
|
||||
expect(isAbsolutePath("..\\file")).toBe(false);
|
||||
expect(isAbsolutePath("file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("Git Bash style paths", () => {
|
||||
expect(isAbsolutePath("/c/Users/name/file")).toBe(true);
|
||||
expect(isAbsolutePath("/C/Users/name/file")).toBe(true);
|
||||
expect(isAbsolutePath("/d/Projects")).toBe(true);
|
||||
expect(isAbsolutePath("/D/Projects")).toBe(true);
|
||||
expect(isAbsolutePath("/z/")).toBe(true);
|
||||
});
|
||||
|
||||
test("Edge cases", () => {
|
||||
expect(isAbsolutePath("")).toBe(false);
|
||||
expect(isAbsolutePath("C:")).toBe(true); // Drive letter only
|
||||
expect(isAbsolutePath("C")).toBe(false); // Just a letter
|
||||
expect(isAbsolutePath(":")).toBe(false);
|
||||
expect(isAbsolutePath("/a")).toBe(true); // Short Unix path
|
||||
expect(isAbsolutePath("/1/")).toBe(true); // Number after slash (not Git Bash)
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// normalizePathSeparators
|
||||
// ===========================================================================
|
||||
|
||||
describe("normalizePathSeparators", () => {
|
||||
test("Windows paths with backslashes", () => {
|
||||
expect(normalizePathSeparators("C:\\Users\\name\\file.txt"))
|
||||
.toBe("C:/Users/name/file.txt");
|
||||
expect(normalizePathSeparators("D:\\Projects\\qmd\\src"))
|
||||
.toBe("D:/Projects/qmd/src");
|
||||
expect(normalizePathSeparators("\\path\\to\\file"))
|
||||
.toBe("/path/to/file");
|
||||
});
|
||||
|
||||
test("Mixed separators", () => {
|
||||
expect(normalizePathSeparators("C:\\Users/name\\file.txt"))
|
||||
.toBe("C:/Users/name/file.txt");
|
||||
expect(normalizePathSeparators("path\\to/file/here"))
|
||||
.toBe("path/to/file/here");
|
||||
});
|
||||
|
||||
test("Unix paths (should remain unchanged)", () => {
|
||||
expect(normalizePathSeparators("/path/to/file"))
|
||||
.toBe("/path/to/file");
|
||||
expect(normalizePathSeparators("/usr/local/bin"))
|
||||
.toBe("/usr/local/bin");
|
||||
expect(normalizePathSeparators("relative/path"))
|
||||
.toBe("relative/path");
|
||||
});
|
||||
|
||||
test("Multiple consecutive backslashes", () => {
|
||||
expect(normalizePathSeparators("path\\\\to\\\\file"))
|
||||
.toBe("path//to//file");
|
||||
expect(normalizePathSeparators("C:\\\\Users\\\\name"))
|
||||
.toBe("C://Users//name");
|
||||
});
|
||||
|
||||
test("Edge cases", () => {
|
||||
expect(normalizePathSeparators("")).toBe("");
|
||||
expect(normalizePathSeparators("\\")).toBe("/");
|
||||
expect(normalizePathSeparators("\\\\")).toBe("//");
|
||||
expect(normalizePathSeparators("file.txt")).toBe("file.txt");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getRelativePathFromPrefix
|
||||
// ===========================================================================
|
||||
|
||||
describe("getRelativePathFromPrefix", () => {
|
||||
test("Exact match (path equals prefix)", () => {
|
||||
expect(getRelativePathFromPrefix("/home/user", "/home/user")).toBe("");
|
||||
expect(getRelativePathFromPrefix("C:/Users/name", "C:/Users/name")).toBe("");
|
||||
expect(getRelativePathFromPrefix("/path", "/path")).toBe("");
|
||||
});
|
||||
|
||||
test("Path under prefix", () => {
|
||||
expect(getRelativePathFromPrefix("/home/user/documents", "/home/user"))
|
||||
.toBe("documents");
|
||||
expect(getRelativePathFromPrefix("/home/user/documents/file.txt", "/home/user"))
|
||||
.toBe("documents/file.txt");
|
||||
expect(getRelativePathFromPrefix("C:/Users/name/Documents/file.txt", "C:/Users/name"))
|
||||
.toBe("Documents/file.txt");
|
||||
});
|
||||
|
||||
test("Path not under prefix", () => {
|
||||
expect(getRelativePathFromPrefix("/home/other", "/home/user")).toBeNull();
|
||||
expect(getRelativePathFromPrefix("/usr/local", "/home/user")).toBeNull();
|
||||
expect(getRelativePathFromPrefix("C:/Users/other", "D:/Users")).toBeNull();
|
||||
});
|
||||
|
||||
test("Windows paths with normalized separators", () => {
|
||||
// Backslashes should be normalized
|
||||
expect(getRelativePathFromPrefix("C:\\Users\\name\\Documents", "C:\\Users\\name"))
|
||||
.toBe("Documents");
|
||||
expect(getRelativePathFromPrefix("C:\\Users\\name\\Documents\\file.txt", "C:/Users/name"))
|
||||
.toBe("Documents/file.txt");
|
||||
});
|
||||
|
||||
test("Prefix with trailing slash", () => {
|
||||
expect(getRelativePathFromPrefix("/home/user/documents", "/home/user/"))
|
||||
.toBe("documents");
|
||||
expect(getRelativePathFromPrefix("C:/Users/name/Documents", "C:/Users/name/"))
|
||||
.toBe("Documents");
|
||||
});
|
||||
|
||||
test("Prefix without trailing slash", () => {
|
||||
expect(getRelativePathFromPrefix("/home/user/documents", "/home/user"))
|
||||
.toBe("documents");
|
||||
expect(getRelativePathFromPrefix("C:/Users/name/Documents", "C:/Users/name"))
|
||||
.toBe("Documents");
|
||||
});
|
||||
|
||||
test("Edge cases", () => {
|
||||
// Empty prefix
|
||||
expect(getRelativePathFromPrefix("/path/to/file", "")).toBeNull();
|
||||
|
||||
// Path is prefix substring but not in hierarchy
|
||||
expect(getRelativePathFromPrefix("/home/username", "/home/user")).toBeNull();
|
||||
|
||||
// Root prefix
|
||||
expect(getRelativePathFromPrefix("/home/user", "/")).toBe("home/user");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// resolve - Unix environment
|
||||
// ===========================================================================
|
||||
|
||||
describe("resolve - Unix environment", () => {
|
||||
beforeEach(() => {
|
||||
mockPWD("/home/user");
|
||||
});
|
||||
|
||||
test("Unix relative paths", () => {
|
||||
expect(resolve("/base", "relative")).toBe("/base/relative");
|
||||
expect(resolve("/base", "a/b/c")).toBe("/base/a/b/c");
|
||||
expect(resolve("/home", "user/documents")).toBe("/home/user/documents");
|
||||
});
|
||||
|
||||
test("Unix absolute paths", () => {
|
||||
expect(resolve("/base", "/absolute")).toBe("/absolute");
|
||||
expect(resolve("/home/user", "/usr/local")).toBe("/usr/local");
|
||||
expect(resolve("/any", "/")).toBe("/");
|
||||
});
|
||||
|
||||
test("Path with .. and .", () => {
|
||||
expect(resolve("/base", "../other")).toBe("/other");
|
||||
expect(resolve("/base/sub", "..")).toBe("/base");
|
||||
expect(resolve("/base", "./file")).toBe("/base/file");
|
||||
expect(resolve("/base/a/b", "../../c")).toBe("/base/c");
|
||||
});
|
||||
|
||||
test("Multiple path segments", () => {
|
||||
expect(resolve("/a", "b", "c")).toBe("/a/b/c");
|
||||
expect(resolve("/a", "b", "../c")).toBe("/a/c");
|
||||
expect(resolve("/a", "b", "/c")).toBe("/c");
|
||||
});
|
||||
|
||||
test("Relative path without base (uses PWD)", () => {
|
||||
expect(resolve("relative")).toBe("/home/user/relative");
|
||||
expect(resolve("a/b/c")).toBe("/home/user/a/b/c");
|
||||
expect(resolve("./file")).toBe("/home/user/file");
|
||||
});
|
||||
|
||||
test("Absolute path alone", () => {
|
||||
expect(resolve("/absolute/path")).toBe("/absolute/path");
|
||||
expect(resolve("/")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// resolve - Windows environment
|
||||
// ===========================================================================
|
||||
|
||||
describe("resolve - Windows environment", () => {
|
||||
beforeEach(() => {
|
||||
mockPWD("C:/Users/name");
|
||||
});
|
||||
|
||||
test("Windows relative paths", () => {
|
||||
expect(resolve("C:/base", "relative")).toBe("C:/base/relative");
|
||||
expect(resolve("C:/base", "a/b/c")).toBe("C:/base/a/b/c");
|
||||
expect(resolve("D:/Projects", "qmd/src")).toBe("D:/Projects/qmd/src");
|
||||
});
|
||||
|
||||
test("Windows absolute paths", () => {
|
||||
expect(resolve("C:/base", "D:/other")).toBe("D:/other");
|
||||
expect(resolve("C:/Users", "C:/Program Files")).toBe("C:/Program Files");
|
||||
expect(resolve("D:/any", "E:/other")).toBe("E:/other");
|
||||
});
|
||||
|
||||
test("Windows with backslashes", () => {
|
||||
expect(resolve("C:\\base", "relative")).toBe("C:/base/relative");
|
||||
expect(resolve("C:\\Users\\name", "Documents")).toBe("C:/Users/name/Documents");
|
||||
expect(resolve("C:\\base", "a\\b\\c")).toBe("C:/base/a/b/c");
|
||||
});
|
||||
|
||||
test("Path with .. and .", () => {
|
||||
expect(resolve("C:/base", "../other")).toBe("C:/other");
|
||||
expect(resolve("C:/base/sub", "..")).toBe("C:/base");
|
||||
expect(resolve("C:/base", "./file")).toBe("C:/base/file");
|
||||
expect(resolve("C:/base/a/b", "../../c")).toBe("C:/base/c");
|
||||
});
|
||||
|
||||
test("Multiple path segments", () => {
|
||||
expect(resolve("C:/a", "b", "c")).toBe("C:/a/b/c");
|
||||
expect(resolve("C:/a", "b", "../c")).toBe("C:/a/c");
|
||||
expect(resolve("C:/a", "b", "D:/c")).toBe("D:/c");
|
||||
});
|
||||
|
||||
test("Relative path without base (uses PWD)", () => {
|
||||
expect(resolve("relative")).toBe("C:/Users/name/relative");
|
||||
expect(resolve("a/b/c")).toBe("C:/Users/name/a/b/c");
|
||||
expect(resolve(".\\file")).toBe("C:/Users/name/file");
|
||||
});
|
||||
|
||||
test("Drive letter only", () => {
|
||||
expect(resolve("C:")).toBe("C:/");
|
||||
expect(resolve("D:")).toBe("D:/");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// resolve - Git Bash style paths
|
||||
// ===========================================================================
|
||||
|
||||
describe("resolve - Git Bash style paths", () => {
|
||||
test("Git Bash to Windows conversion", () => {
|
||||
expect(resolve("/c/Users/name")).toBe("C:/Users/name");
|
||||
expect(resolve("/C/Users/name")).toBe("C:/Users/name");
|
||||
expect(resolve("/d/Projects")).toBe("D:/Projects");
|
||||
expect(resolve("/D/Projects")).toBe("D:/Projects");
|
||||
});
|
||||
|
||||
test("Git Bash with relative paths", () => {
|
||||
expect(resolve("/c/base", "relative")).toBe("C:/base/relative");
|
||||
expect(resolve("/d/Projects", "qmd/src")).toBe("D:/Projects/qmd/src");
|
||||
});
|
||||
|
||||
test("Git Bash with .. and .", () => {
|
||||
expect(resolve("/c/base", "../other")).toBe("C:/other");
|
||||
expect(resolve("/c/base/sub", "..")).toBe("C:/base");
|
||||
expect(resolve("/c/base", "./file")).toBe("C:/base/file");
|
||||
});
|
||||
|
||||
test("Multiple Git Bash segments", () => {
|
||||
expect(resolve("/c/a", "b", "c")).toBe("C:/a/b/c");
|
||||
expect(resolve("/c/a", "b", "/d/c")).toBe("D:/c");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// resolve - Edge cases and mixed scenarios
|
||||
// ===========================================================================
|
||||
|
||||
describe("resolve - Edge cases", () => {
|
||||
test("Empty path segments are filtered", () => {
|
||||
expect(resolve("/base", "", "file")).toBe("/base/file");
|
||||
expect(resolve("C:/base", "", "file")).toBe("C:/base/file");
|
||||
});
|
||||
|
||||
test("Multiple consecutive slashes", () => {
|
||||
expect(resolve("/base//path///file")).toBe("/base/path/file");
|
||||
expect(resolve("C:/base//path///file")).toBe("C:/base/path/file");
|
||||
});
|
||||
|
||||
test("Trailing slashes", () => {
|
||||
expect(resolve("/base/", "file")).toBe("/base/file");
|
||||
expect(resolve("C:/base/", "file")).toBe("C:/base/file");
|
||||
});
|
||||
|
||||
test("Complex .. navigation", () => {
|
||||
expect(resolve("/a/b/c/d", "../../../e")).toBe("/a/e");
|
||||
expect(resolve("C:/a/b/c/d", "../../../e")).toBe("C:/a/e");
|
||||
});
|
||||
|
||||
test("Too many .. (should not go above root)", () => {
|
||||
expect(resolve("/base", "../../../../other")).toBe("/other");
|
||||
expect(resolve("C:/base", "../../../../other")).toBe("C:/other");
|
||||
});
|
||||
|
||||
test("Mixed Unix and Windows (normalized)", () => {
|
||||
mockPWD("C:/Users/name");
|
||||
expect(resolve("/unix/path")).toBe("/unix/path");
|
||||
expect(resolve("relative")).toBe("C:/Users/name/relative");
|
||||
});
|
||||
|
||||
test("Error on no arguments", () => {
|
||||
expect(() => resolve()).toThrow("resolve: at least one path segment is required");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user