Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a573d33d0a | |||
| d92a46ed8b | |||
| 3ab8542cf4 | |||
| b7b3a185a0 | |||
| d330b94816 | |||
| 3d9be7dfdb | |||
| c2c2d51da5 | |||
| 624df32c37 | |||
| f6edc321e3 | |||
| 51208e2031 | |||
| 99a644ba96 | |||
| 949e7d77ff | |||
| db96858222 | |||
| e7b753014c | |||
| 5c2078aebb | |||
| af9f0d165a | |||
| 5350df92dd | |||
| 68ac64cc02 | |||
| 96f201bf3f | |||
| ae747a4470 | |||
| 3d3b996839 | |||
| f1afba70ff | |||
| f98afcd6b0 | |||
| fbb744ba6b | |||
| 9d4eb9a62a | |||
| 2db7c34920 | |||
| 2f739c6f21 | |||
| 106abfa718 | |||
| 3c7c4d58bb | |||
| 3abacb5d6e | |||
| 64ee7763fe | |||
| 158d5f68e6 | |||
| 95a6954b50 | |||
| 8e338afeac | |||
| f09e134948 | |||
| a8d8936b11 | |||
| cb621c83b5 | |||
| cc97d77810 | |||
| ce109cf6fb | |||
| fae74ade95 | |||
| 4f3472a49c | |||
| b62ad15be8 | |||
| 76887ea8ca | |||
| 1ccd637149 | |||
| 6c438f7a4d | |||
| 70bf963f28 | |||
| 810676ff21 | |||
| 138890b9d2 | |||
| 1ed2e39c3d | |||
| 97cc4ed5fe | |||
| 26e178fa96 | |||
| 3342d7d955 | |||
| 9367811c5a | |||
| 4ea2734ade | |||
| 44cff07ea1 | |||
| d31c989423 |
@@ -4,12 +4,18 @@ A plugin that integrates [Ollama](https://ollama.ai) with Obsidian, allowing you
|
||||
|
||||
## Features
|
||||
|
||||
- Chat with Ollama models directly in Obsidian
|
||||
- Vault context search — the assistant can reference your notes
|
||||
- Tool integration — create files based on chat responses
|
||||
- Streaming responses
|
||||
- Semantic response cache — repeated or similar queries are answered instantly without hitting the model (requires ChromaDB)
|
||||
- Customisable model, URL, and cache settings
|
||||
- **Chat with Ollama models** directly in Obsidian with streaming responses
|
||||
- **Vault context search** — the assistant can reference your notes via semantic (RAG) or keyword search
|
||||
- **Agent Modes** — selectable chat modes (Ask, Edit, Organize, Research, Workflow) that change available tools, system prompts, and preview behaviour
|
||||
- **Tool integration** — create, read, search, append, edit, rename, move, delete notes, and insert wiki-links
|
||||
- **Structured Memory** — persist conversation summaries, user preferences, and learned facts across sessions
|
||||
- **Tool Telemetry** — track which tools were called, which notes were searched, and LLM token usage
|
||||
- **Semantic/RAG vault indexing** — automatically index your vault into a vector database for intelligent retrieval
|
||||
- **Semantic response cache** — repeated or similar queries are answered instantly without hitting the model
|
||||
- **Workflow Engine** — execute multi-step AI workflows via `/workflow` commands
|
||||
- **Auto-Organizer** — AI-powered auto-tagging and auto-linking with dry-run preview and folder scoping
|
||||
- **Obsidian MetadataCache integration** — frontmatter, tags, links, and headings are read via Obsidian's built-in cache instead of raw regex parsing
|
||||
- Customisable model, URL, cache, and memory settings
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -17,6 +23,24 @@ A plugin that integrates [Ollama](https://ollama.ai) with Obsidian, allowing you
|
||||
2. **Start Ollama**: `ollama serve`
|
||||
3. **Pull a chat model**: `ollama pull llama3` (or any other model you prefer)
|
||||
|
||||
### Optional — Vault Semantic Index (RAG)
|
||||
|
||||
The vault semantic index automatically indexes your Obsidian notes into a local [ChromaDB](https://www.trychroma.com) vector database. When you ask a question, the plugin performs semantic search against your notes and includes the most relevant passages as context for the AI.
|
||||
|
||||
1. **Install ChromaDB**:
|
||||
```bash
|
||||
pip install chromadb
|
||||
```
|
||||
2. **Start ChromaDB**:
|
||||
```bash
|
||||
chroma run --host localhost --port 8000
|
||||
```
|
||||
3. **Pull an embedding model**:
|
||||
```bash
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
4. Enable the vault semantic index in the plugin settings and configure the ChromaDB URL.
|
||||
|
||||
### Optional — Semantic Cache
|
||||
|
||||
The semantic cache stores responses in a local [ChromaDB](https://www.trychroma.com) vector database. When you ask a question that is semantically similar to one already cached, the stored answer is returned immediately instead of calling the model.
|
||||
@@ -65,11 +89,13 @@ Then copy the plugin into your vault:
|
||||
```bash
|
||||
mkdir -p /path/to/vault/.obsidian/plugins/ollama-plugin
|
||||
cp manifest.json /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||
cp -r dist /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||
cp -r node_modules/chromadb /path/to/vault/.obsidian/plugins/ollama-plugin/node_modules/ # optional: only needed for semantic cache
|
||||
cp main.js /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||
cp styles.css /path/to/vault/.obsidian/plugins/ollama-plugin/
|
||||
# Remove old dist/ from previous installs (no longer needed with bundling)
|
||||
rm -rf /path/to/vault/.obsidian/plugins/ollama-plugin/dist
|
||||
```
|
||||
|
||||
> **Note:** The `obsidian` npm package is a dev-only type stub — Obsidian provides its own API at runtime. The `chromadb` package is only needed if you enable the semantic cache feature.
|
||||
> **Note:** The plugin is now bundled into a single `main.js` via esbuild. The `obsidian` npm package is a dev-only type stub — Obsidian provides its own API at runtime. The `chromadb` client library is also bundled into `main.js`, so no extra `node_modules` copy is needed for the semantic cache feature.
|
||||
|
||||
### After installation
|
||||
|
||||
@@ -84,14 +110,39 @@ Open **Settings → Ollama Settings** to configure the plugin.
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Ollama URL | `http://localhost:11434` | Base URL of your Ollama instance |
|
||||
| Model | `llama3` | Model used for chat responses |
|
||||
| Vault Search Limit | `3` | Maximum number of vault entries to include in context |
|
||||
| Chat Model | `deepseek-v4-flash` | Model used for normal chat, Ask mode, and Research mode |
|
||||
| Agent Model | `glm-5.1` | Model used for Edit, Organize, Workflow, and auto-organizer tasks |
|
||||
| **Default Agent Mode** | `Ask` | Default chat mode (Ask, Edit, Organize, Research, Workflow) |
|
||||
| Vault Search Limit | `5` | Maximum number of vault entries to include in context |
|
||||
| Max Context Length | `8000` | Maximum characters of vault content sent to the AI per message |
|
||||
| Max Message History | `50` | Maximum number of messages kept in conversation history |
|
||||
| **Enable Vault Semantic Index** | Off | Index vault notes into a vector DB for semantic/RAG search |
|
||||
| Vault Index ChromaDB URL | `http://localhost:8000` | URL of your ChromaDB instance for the vault index |
|
||||
| Vault Index Embedding Model | `nomic-embed-text` | Ollama model used to generate vault embeddings |
|
||||
| Vault Index Similarity Threshold | `0.75` | Minimum cosine similarity (0–1) for a vault search hit |
|
||||
| Rebuild Vault Index | — | Button to rebuild the entire vault semantic index |
|
||||
| Clear Vault Index | — | Button to delete all indexed vault notes |
|
||||
| Enable Semantic Cache | Off | Cache responses for fast repeated queries |
|
||||
| ChromaDB URL | `http://localhost:8000` | URL of your running ChromaDB instance |
|
||||
| Cache Embedding Model | `nomic-embed-text` | Ollama model used to generate cache embeddings |
|
||||
| Cache Similarity Threshold | `0.85` | Minimum cosine similarity (0–1) for a cache hit — higher values require closer matches |
|
||||
| Clear Semantic Cache | — | Button to wipe all cached responses from ChromaDB |
|
||||
| Cache Similarity Threshold | `0.85` | Minimum cosine similarity (0–1) for a cache hit |
|
||||
| Clear Semantic Cache | — | Button to wipe all cached responses |
|
||||
| **Enable Auto-Tagging** | Off | Automatically suggest and apply tags to untagged notes |
|
||||
| Max Tags Per Note | `5` | Maximum tags to generate per note |
|
||||
| Normalize Tags | On | Normalize generated tags against existing vault vocabulary |
|
||||
| Target Folder (Auto-Tag) | — | Restrict auto-tagging to a specific folder |
|
||||
| **Enable Auto-Linking** | Off | Add "Related Notes" sections based on semantic similarity |
|
||||
| Max Links Per Note | `3` | Maximum related note links to insert |
|
||||
| Target Folder (Auto-Link) | — | Restrict auto-linking to a specific folder |
|
||||
| Dry Run Mode (Auto-Link) | Off | Preview proposed links without applying them |
|
||||
| **Enable Structured Memory** | On | Inject remembered context from past sessions into prompts |
|
||||
| Max Conversation Summaries | `10` | Maximum past conversation summaries to retain |
|
||||
| Max User Preferences | `20` | Maximum user preferences to retain |
|
||||
| Max Learned Facts | `50` | Maximum learned facts to retain |
|
||||
| Clear Structured Memory | — | Button to delete all stored memory |
|
||||
| **Enable Tool Telemetry** | On | Record tool calls, searches, and LLM token counts |
|
||||
| Max Telemetry Entries | `100` | Maximum telemetry events to retain |
|
||||
| Clear Tool Telemetry | — | Button to delete all recorded telemetry |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -101,6 +152,52 @@ Open **Settings → Ollama Settings** to configure the plugin.
|
||||
4. Press **Shift+Enter** to insert a line break
|
||||
5. Click **New Chat** to start a fresh conversation
|
||||
|
||||
### Agent Modes
|
||||
|
||||
The chat view includes a mode selector dropdown. Each mode changes the assistant's behaviour:
|
||||
|
||||
| Mode | Tools Available | Preview Required | Use Case |
|
||||
|------|----------------|------------------|----------|
|
||||
| **Ask** | Read, Search | No | Answer questions using vault context |
|
||||
| **Edit** | All tools | Yes | Create, modify, and manage notes |
|
||||
| **Organize** | Read, Search, Frontmatter, Rename, Move, Link | Yes | Tag, rename, move, and link notes |
|
||||
| **Research** | Read, Search | No | Deep vault search and synthesis |
|
||||
| **Workflow** | None (uses `/workflow`) | No | Execute multi-step AI workflows |
|
||||
|
||||
When a mode requires preview (Edit, Organize), write operations like `create_note` or `delete_note` show a card with a before/after diff and **Apply** / **Cancel** buttons. Ask and Research modes execute write tools immediately without preview.
|
||||
|
||||
### Workflows
|
||||
|
||||
Type `/workflow` followed by a description to trigger the workflow engine. The AI will generate a multi-step workflow plan, then execute it step-by-step. Example:
|
||||
|
||||
```
|
||||
/workflow Find all notes tagged "meeting", summarise them, and create a "Meeting Summary" note
|
||||
```
|
||||
|
||||
### Auto-Organizer
|
||||
|
||||
Use the command palette to trigger:
|
||||
- **Auto-Tag Untagged Notes** — AI generates tags for notes missing tags
|
||||
- **Auto-Link Related Notes** — AI inserts "Related Notes" sections with wiki-links
|
||||
|
||||
Both features support:
|
||||
- **Dry-run mode** — preview proposed changes without modifying the vault
|
||||
- **Target folder** — restrict processing to a specific folder and its subfolders
|
||||
- **Tag normalisation** — match generated tags against existing vault vocabulary
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Open Ollama Chat | Open the chat sidebar |
|
||||
| Clear Semantic Cache | Delete all cached responses |
|
||||
| Clear Vault Index | Delete all indexed vault notes |
|
||||
| Rebuild Vault Index | Rebuild the vault semantic index from scratch |
|
||||
| Auto-Tag Untagged Notes | Run the auto-tagger |
|
||||
| Auto-Link Related Notes | Run the auto-linker |
|
||||
| Clear Structured Memory | Delete all conversation summaries, preferences, and facts |
|
||||
| Clear Tool Telemetry | Delete all recorded telemetry events |
|
||||
|
||||
## Semantic Cache Behaviour
|
||||
|
||||
- The cache is **bypassed** when tool calls are involved (e.g. file creation), since those requests have side effects.
|
||||
@@ -110,7 +207,7 @@ Open **Settings → Ollama Settings** to configure the plugin.
|
||||
|
||||
## Vault Context
|
||||
|
||||
When you send a message, the plugin automatically searches your vault for relevant notes and includes them as context. The search uses weighted scoring:
|
||||
When you send a message, the plugin searches your vault for relevant notes and includes them as context. If the **Vault Semantic Index** is enabled, search is performed via semantic/RAG retrieval using vector embeddings. Otherwise, it falls back to a weighted keyword search:
|
||||
|
||||
- **Headings** — 5x weight
|
||||
- **Frontmatter title** — 3x weight
|
||||
@@ -118,9 +215,57 @@ When you send a message, the plugin automatically searches your vault for releva
|
||||
- **First paragraph** — 1.5x weight
|
||||
- **General content** — 1x weight
|
||||
|
||||
The plugin also pulls in:
|
||||
- **Explicit mentions** — notes referenced via `[[...]]` wikilinks in the message
|
||||
- **Open note** — the currently active note
|
||||
- **Selected text** — text selected in the active editor
|
||||
- **Backlinks / Outlinks** — notes that link to / from the open note
|
||||
- **Related notes** — semantically similar notes (requires vault semantic index)
|
||||
|
||||
The plugin automatically watches your vault for changes (create, modify, delete, rename) and updates the semantic index in real time when enabled.
|
||||
|
||||
Frontmatter, tags, links, and headings are resolved using Obsidian's built-in `metadataCache` API for accuracy and performance.
|
||||
|
||||
## Tools
|
||||
|
||||
The plugin exposes a `create_file` tool that allows the AI to create new markdown files in your vault. Paths are validated for safety (no `.obsidian`/`.git` access, no path traversal).
|
||||
The assistant has access to a suite of tools that interact with your vault. Available tools depend on the current **Agent Mode**:
|
||||
|
||||
| Tool | Description | Mode |
|
||||
|------|-------------|------|
|
||||
| `create_note` / `create_file` | Create a new markdown file | Edit |
|
||||
| `read_vault_file` | Read the contents of a note | All |
|
||||
| `search_vault_files` | Keyword-search vault files by path | All |
|
||||
| `append_to_note` | Append text to the end of a note | Edit |
|
||||
| `replace_note_section` | Replace content under a specific heading | Edit |
|
||||
| `update_frontmatter` | Add, update, or remove frontmatter fields | Edit, Organize |
|
||||
| `rename_note` | Rename a note file | Edit, Organize |
|
||||
| `move_note` | Move a note to a different folder | Edit, Organize |
|
||||
| `delete_note` | Delete a note | Edit |
|
||||
| `insert_link` | Insert a `[[wiki-link]]` into a note | Edit, Organize |
|
||||
|
||||
Paths are validated for safety: no `.obsidian`/`.git` access, no path traversal (`..`), no absolute paths, and a 200-character limit.
|
||||
|
||||
## Structured Memory
|
||||
|
||||
When **Enable Structured Memory** is on, the plugin remembers context across sessions by storing three kinds of data in Obsidian's plugin data JSON:
|
||||
|
||||
- **Conversation Summaries** — After each assistant reply, a brief summary (topic + key points) is saved
|
||||
- **User Preferences** — Statements like "I prefer dark mode" or "My favourite colour is blue" are extracted and stored
|
||||
- **Learned Facts** — Simple facts mentioned in conversation (e.g., "Obsidian is a note-taking app") and vault folder paths are remembered
|
||||
|
||||
These are injected as a system message at the start of every LLM call, so the assistant "remembers" context from previous sessions. Limits and clear controls are available in settings.
|
||||
|
||||
## Tool Telemetry
|
||||
|
||||
When **Enable Tool Telemetry** is on, the plugin records:
|
||||
|
||||
- **Tool calls** — which tool, arguments, success/failure, result summary, and duration
|
||||
- **LLM calls** — model, estimated prompt/completion/total tokens, and duration
|
||||
- **Vault searches** — query, number of results, and matched note paths
|
||||
|
||||
Telemetry is stored locally in Obsidian's plugin data. The settings tab shows a **Recent Activity** summary of the last 10 events. Use **Clear Tool Telemetry** to wipe the history.
|
||||
|
||||
> **Note:** Token counts are exact when Ollama provides `prompt_eval_count` and `eval_count` in its response; otherwise they are estimated from character count (÷4 approximation).
|
||||
|
||||
## Supported Models
|
||||
|
||||
@@ -137,9 +282,12 @@ Any Ollama-supported model works. Popular choices:
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
npm test # 520+ unit tests across 21 test suites
|
||||
npm run lint # ESLint check
|
||||
```
|
||||
|
||||
The project uses TypeScript, Jest, and esbuild. Obsidian APIs are mocked in `__mocks__/obsidian.ts` for testing.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
@@ -147,10 +295,13 @@ npm test
|
||||
| Plugin doesn't appear in Obsidian | Install script was not run or failed | Run `./install.sh /path/to/vault` and reload Obsidian |
|
||||
| Cannot connect to Ollama | Ollama is not running | Run `ollama serve` |
|
||||
| Model not found | Model not pulled | Run `ollama pull <model>` |
|
||||
| "Invalid response format" error | Ollama returned a non-JSON response (e.g., proxy error page) | Check that Ollama is healthy at the configured URL |
|
||||
| Semantic cache unavailable (notice shown) | ChromaDB is not running, or the ChromaDB URL is wrong | Start ChromaDB (`chroma run`) and verify the URL in settings |
|
||||
| Cache always misses | Similarity threshold is too high, or the embedding model was changed | Lower the threshold or click **Clear Semantic Cache** and let the cache rebuild |
|
||||
| Slow first response after enabling cache | Embedding model not yet pulled | Run `ollama pull nomic-embed-text` (or the model you configured) |
|
||||
| Permission issues | Vault write permissions | Check that your Obsidian vault has proper write permissions |
|
||||
| Structured memory not showing up | Memory was just cleared or is empty | Have a few conversations — summaries are generated after each assistant reply |
|
||||
| Tool telemetry not recording | Telemetry is disabled or max entries is 0 | Enable **Tool Telemetry** in settings and set **Max Telemetry Entries** > 0 |
|
||||
|
||||
## Security
|
||||
|
||||
@@ -163,7 +314,7 @@ npm test
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Flo Egger
|
||||
Copyright (c) 2026 Florian Egger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -5,11 +5,13 @@ export class Vault {
|
||||
getMarkdownFiles: () => any[];
|
||||
read: (file: any) => Promise<string>;
|
||||
create: (path: string, content: string) => Promise<any>;
|
||||
createFolder: (path: string) => Promise<any>;
|
||||
|
||||
constructor() {
|
||||
this.getMarkdownFiles = () => [];
|
||||
this.read = async () => '';
|
||||
this.create = async () => null;
|
||||
this.createFolder = async () => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +31,20 @@ export class Workspace {
|
||||
export class App {
|
||||
vault: Vault;
|
||||
workspace: Workspace;
|
||||
metadataCache: {
|
||||
getFileCache: jest.Mock;
|
||||
getFirstLinkpathDest: jest.Mock;
|
||||
resolvedLinks: Record<string, Record<string, number>>;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.vault = new Vault();
|
||||
this.workspace = new Workspace();
|
||||
this.metadataCache = {
|
||||
getFileCache: jest.fn().mockReturnValue(null),
|
||||
getFirstLinkpathDest: jest.fn().mockReturnValue(null),
|
||||
resolvedLinks: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +115,14 @@ export interface TFile {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export class TFolder {
|
||||
path: string;
|
||||
|
||||
constructor(path: string = '') {
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin class (used by main.ts)
|
||||
export class Plugin {
|
||||
app: App;
|
||||
@@ -110,4 +130,8 @@ export class Plugin {
|
||||
constructor() {
|
||||
this.app = new App();
|
||||
}
|
||||
|
||||
addRibbonIcon(_icon: string, _title: string, _callback: () => void): HTMLElement {
|
||||
return document.createElement('div');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
chroma:
|
||||
image: chromadb/chroma:latest
|
||||
expose:
|
||||
- '8000'
|
||||
environment:
|
||||
- CHROMA_SERVER_HOST=0.0.0.0
|
||||
- CHROMA_SERVER_HTTP_PORT=8000
|
||||
volumes:
|
||||
- chroma_data:/chroma/chroma
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- '8666:8666'
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- chroma
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
chroma_data:
|
||||
+12
-14
@@ -76,7 +76,7 @@ info "npm $(npm --version)"
|
||||
step "Installing npm dependencies"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --production
|
||||
npm install
|
||||
info "Dependencies installed"
|
||||
|
||||
# ── Build ────────────────────────────────────────────────────────────────────
|
||||
@@ -92,18 +92,15 @@ step "Installing into vault"
|
||||
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
|
||||
# Copy manifest and built output
|
||||
cp "$SCRIPT_DIR/manifest.json" "$PLUGIN_DIR/"
|
||||
cp -r "$SCRIPT_DIR/dist" "$PLUGIN_DIR/"
|
||||
# Remove old dist/ directory from previous installations (no longer needed with bundling)
|
||||
rm -rf "$PLUGIN_DIR/dist"
|
||||
|
||||
# Copy only necessary runtime dependencies (obsidian and chromadb stubs)
|
||||
# The obsidian package is only used for type definitions at build time —
|
||||
# at runtime Obsidian provides its own API, so we don't need it in node_modules.
|
||||
# chromadb is loaded dynamically and only needed when the cache is enabled.
|
||||
mkdir -p "$PLUGIN_DIR/node_modules"
|
||||
if [ -d "$SCRIPT_DIR/node_modules/chromadb" ]; then
|
||||
cp -r "$SCRIPT_DIR/node_modules/chromadb" "$PLUGIN_DIR/node_modules/"
|
||||
fi
|
||||
# Copy manifest, bundled entry point, and styles
|
||||
cp "$SCRIPT_DIR/manifest.json" "$PLUGIN_DIR/"
|
||||
cp "$SCRIPT_DIR/main.js" "$PLUGIN_DIR/"
|
||||
cp "$SCRIPT_DIR/styles.css" "$PLUGIN_DIR/"
|
||||
|
||||
# All dependencies are now bundled into main.js; no runtime node_modules needed.
|
||||
|
||||
info "Plugin installed to $PLUGIN_DIR"
|
||||
|
||||
@@ -111,9 +108,10 @@ info "Plugin installed to $PLUGIN_DIR"
|
||||
|
||||
step "Verifying installation"
|
||||
|
||||
if [ -f "$PLUGIN_DIR/manifest.json" ] && [ -f "$PLUGIN_DIR/dist/main.js" ]; then
|
||||
if [ -f "$PLUGIN_DIR/manifest.json" ] && [ -f "$PLUGIN_DIR/main.js" ] && [ -f "$PLUGIN_DIR/styles.css" ]; then
|
||||
info "manifest.json ✓"
|
||||
info "dist/main.js ✓"
|
||||
info "main.js ✓"
|
||||
info "styles.css ✓"
|
||||
else
|
||||
error "Installation verification failed — missing files in $PLUGIN_DIR"
|
||||
exit 1
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"author": "Anonymous",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": true,
|
||||
"main": "dist/main.js",
|
||||
"main": "main.js",
|
||||
"authorization": [],
|
||||
"permissions": [],
|
||||
"defaultEnabled": true,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 8666;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
# CORS headers must be set for every response, including OPTIONS preflight
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
|
||||
# Preflight OPTIONS
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://chroma:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+498
-94
@@ -9,18 +9,18 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chromadb": "^1.5.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"obsidian": "^1.4.11"
|
||||
"chromadb": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"obsidian": "^1.4.11",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.3.3"
|
||||
@@ -567,6 +567,7 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
|
||||
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -577,6 +578,7 @@
|
||||
"version": "6.38.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -701,6 +703,448 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -1559,6 +2003,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
@@ -1676,6 +2121,7 @@
|
||||
"version": "5.60.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
|
||||
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/tern": "*"
|
||||
@@ -1685,6 +2131,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
@@ -1768,6 +2215,7 @@
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
@@ -2635,6 +3083,7 @@
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
@@ -2667,15 +3116,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
@@ -2839,6 +3279,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -3142,29 +3624,6 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -3230,18 +3689,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -4932,6 +5379,7 @@
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
@@ -4958,44 +5406,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -5044,6 +5454,7 @@
|
||||
"version": "1.12.3",
|
||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
|
||||
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/codemirror": "5.60.8",
|
||||
@@ -5786,6 +6197,7 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
@@ -6192,6 +6604,7 @@
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
@@ -6218,15 +6631,6 @@
|
||||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
|
||||
+4
-3
@@ -5,7 +5,7 @@
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"build": "tsc --noEmit && node -e \"require('esbuild').build({entryPoints:['src/main.ts'],bundle:true,platform:'node',target:'es2020',outfile:'main.js',external:['obsidian','cohere-ai','ollama','openai','@google/generative-ai','voyageai'],format:'cjs'})\"",
|
||||
"watch": "tsc --watch",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"format": "prettier --write ."
|
||||
@@ -24,15 +24,16 @@
|
||||
"@types/node": "^20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"obsidian": "^1.4.11",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"chromadb": "^1.5.3",
|
||||
"obsidian": "^1.4.11"
|
||||
"chromadb": "^1.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// src/action-preview-builder.ts
|
||||
|
||||
import { Vault, TFile, App } from 'obsidian';
|
||||
import type { ToolCall, ProposedAction } from './types';
|
||||
import { safeParseJson } from './utils';
|
||||
|
||||
const WRITE_TOOLS = new Set([
|
||||
'create_file',
|
||||
'create_note',
|
||||
'append_to_note',
|
||||
'replace_note_section',
|
||||
'update_frontmatter',
|
||||
'rename_note',
|
||||
'move_note',
|
||||
'delete_note',
|
||||
'insert_link',
|
||||
]);
|
||||
|
||||
export function isWriteTool(name: string): boolean {
|
||||
return WRITE_TOOLS.has(name);
|
||||
}
|
||||
|
||||
export class ActionPreviewBuilder {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
|
||||
constructor(vault: Vault, app: App) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
private parseArgs(toolCall: ToolCall): Record<string, unknown> {
|
||||
const rawArgs = toolCall.function?.arguments;
|
||||
if (typeof rawArgs === 'string') {
|
||||
try {
|
||||
return safeParseJson(rawArgs) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||
return rawArgs;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async buildPreview(toolCall: ToolCall): Promise<ProposedAction> {
|
||||
const name = toolCall.function?.name ?? '';
|
||||
const args = this.parseArgs(toolCall);
|
||||
|
||||
switch (name) {
|
||||
case 'create_file':
|
||||
case 'create_note':
|
||||
return this.buildCreatePreview(toolCall, args);
|
||||
case 'append_to_note':
|
||||
return this.buildAppendPreview(toolCall, args);
|
||||
case 'replace_note_section':
|
||||
return this.buildReplaceSectionPreview(toolCall, args);
|
||||
case 'update_frontmatter':
|
||||
return this.buildUpdateFrontmatterPreview(toolCall, args);
|
||||
case 'rename_note':
|
||||
return this.buildRenamePreview(toolCall, args);
|
||||
case 'move_note':
|
||||
return this.buildMovePreview(toolCall, args);
|
||||
case 'delete_note':
|
||||
return this.buildDeletePreview(toolCall, args);
|
||||
case 'insert_link':
|
||||
return this.buildInsertLinkPreview(toolCall, args);
|
||||
default:
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'read',
|
||||
path: '',
|
||||
description: `Unknown operation: ${name}`,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private strArg(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
private buildCreatePreview(toolCall: ToolCall, args: Record<string, unknown>): ProposedAction {
|
||||
const path = this.strArg(args.path);
|
||||
const content = this.strArg(args.content);
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'create',
|
||||
path,
|
||||
description: `Create note: ${path}`,
|
||||
preview: {
|
||||
before: undefined,
|
||||
after: content,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private async buildAppendPreview(
|
||||
toolCall: ToolCall,
|
||||
args: Record<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
const path = this.strArg(args.path);
|
||||
const content = this.strArg(args.content);
|
||||
const before = await this.readFileSafe(path);
|
||||
const separator = before && before.endsWith('\n') ? '' : '\n';
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'append',
|
||||
path,
|
||||
description: `Append to note: ${path}`,
|
||||
preview: {
|
||||
before,
|
||||
after: before ? before + separator + content : content,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private async buildReplaceSectionPreview(
|
||||
toolCall: ToolCall,
|
||||
args: Record<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
const path = this.strArg(args.path);
|
||||
const heading = this.strArg(args.heading);
|
||||
const content = this.strArg(args.content);
|
||||
const before = await this.readFileSafe(path);
|
||||
let after = before ?? '';
|
||||
|
||||
if (before) {
|
||||
const file = this.getFileSafe(path);
|
||||
const cache = file ? this.app.metadataCache.getFileCache(file) : null;
|
||||
if (cache?.headings) {
|
||||
const targetHeading = cache.headings.find((h) => h.heading === heading);
|
||||
if (targetHeading) {
|
||||
const startOffset = targetHeading.position.start.offset;
|
||||
const headingLevel = targetHeading.level;
|
||||
// Find the next heading at the same or higher level (fewer #)
|
||||
const nextHeading = cache.headings.find(
|
||||
(h) => h.position.start.offset > startOffset && h.level <= headingLevel
|
||||
);
|
||||
const sectionEnd = nextHeading ? nextHeading.position.start.offset : before.length;
|
||||
after =
|
||||
before.slice(0, startOffset) +
|
||||
'#'.repeat(headingLevel) +
|
||||
' ' +
|
||||
heading +
|
||||
'\n' +
|
||||
content +
|
||||
'\n' +
|
||||
before.slice(sectionEnd);
|
||||
}
|
||||
} else {
|
||||
// Fallback to regex when metadataCache is unavailable
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const headingRegex = new RegExp(`^(#{1,6}\\s+)${escapedHeading}\\s*$`, 'm');
|
||||
const match = before.match(headingRegex);
|
||||
if (match) {
|
||||
const headingLevel = match[1].length;
|
||||
const headingIndex = match.index!;
|
||||
const afterHeading = headingIndex + match[0].length;
|
||||
const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}}\\s)`, 'm');
|
||||
const nextMatch = nextHeadingRegex.exec(before.slice(afterHeading));
|
||||
const sectionEnd = nextMatch ? afterHeading + nextMatch.index : before.length;
|
||||
after =
|
||||
before.slice(0, headingIndex) +
|
||||
match[0] +
|
||||
'\n' +
|
||||
content +
|
||||
'\n' +
|
||||
before.slice(sectionEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'replace_section',
|
||||
path,
|
||||
description: `Replace section "${heading}" in ${path}`,
|
||||
preview: {
|
||||
before,
|
||||
after,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private async buildUpdateFrontmatterPreview(
|
||||
toolCall: ToolCall,
|
||||
args: Record<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
const path = this.strArg(args.path);
|
||||
const fields = args.fields as Record<string, unknown> | undefined;
|
||||
const before = await this.readFileSafe(path);
|
||||
let after = before ?? '';
|
||||
|
||||
if (fields && typeof fields === 'object' && !Array.isArray(fields)) {
|
||||
const file = this.getFileSafe(path);
|
||||
const parsed = this.parseFrontmatter(file, before ?? '');
|
||||
const newFields: Record<string, unknown> = { ...parsed.fields };
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === null || value === undefined) {
|
||||
delete newFields[key];
|
||||
} else {
|
||||
newFields[key] = value;
|
||||
}
|
||||
}
|
||||
const newFrontmatter = this.serializeFrontmatter(newFields);
|
||||
const body = parsed.exists
|
||||
? (before ?? '').replace(/^---\n[\s\S]*?\n---\n/, '')
|
||||
: (before ?? '');
|
||||
after = newFrontmatter + body;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'update_frontmatter',
|
||||
path,
|
||||
description: `Update frontmatter in ${path}`,
|
||||
preview: {
|
||||
before,
|
||||
after,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private buildRenamePreview(toolCall: ToolCall, args: Record<string, unknown>): ProposedAction {
|
||||
const oldPath = this.strArg(args.oldPath);
|
||||
const newPath = this.strArg(args.newPath);
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'rename',
|
||||
path: oldPath,
|
||||
description: `Rename ${oldPath} to ${newPath}`,
|
||||
preview: {
|
||||
before: oldPath,
|
||||
after: newPath,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private buildMovePreview(toolCall: ToolCall, args: Record<string, unknown>): ProposedAction {
|
||||
const path = this.strArg(args.path);
|
||||
const folder = this.strArg(args.folder);
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
const newPath = folder ? `${folder}/${fileName}` : fileName;
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'move',
|
||||
path,
|
||||
description: `Move ${path} to ${newPath}`,
|
||||
preview: {
|
||||
before: path,
|
||||
after: newPath,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private async buildDeletePreview(
|
||||
toolCall: ToolCall,
|
||||
args: Record<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
const path = this.strArg(args.path);
|
||||
const before = await this.readFileSafe(path);
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'delete',
|
||||
path,
|
||||
description: `Delete note: ${path}`,
|
||||
preview: {
|
||||
before,
|
||||
after: undefined,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private async buildInsertLinkPreview(
|
||||
toolCall: ToolCall,
|
||||
args: Record<string, unknown>
|
||||
): Promise<ProposedAction> {
|
||||
const sourcePath = this.strArg(args.sourcePath);
|
||||
const targetPath = this.strArg(args.targetPath);
|
||||
const anchorText = args.anchorText;
|
||||
const before = await this.readFileSafe(sourcePath);
|
||||
const linkText =
|
||||
typeof anchorText === 'string' && anchorText.trim()
|
||||
? `[[${targetPath}|${anchorText}]]`
|
||||
: `[[${targetPath}]]`;
|
||||
const separator = before && before.endsWith('\n') ? '' : '\n';
|
||||
const after = before ? before + separator + linkText + '\n' : linkText + '\n';
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCall,
|
||||
operation: 'insert_link',
|
||||
path: sourcePath,
|
||||
description: `Insert link to ${targetPath} in ${sourcePath}`,
|
||||
preview: {
|
||||
before,
|
||||
after,
|
||||
},
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private getFileSafe(path: string): TFile | null {
|
||||
try {
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return file;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async readFileSafe(path: string): Promise<string | undefined> {
|
||||
const file = this.getFileSafe(path);
|
||||
if (file) {
|
||||
try {
|
||||
return await this.vault.cachedRead(file);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseFrontmatter(
|
||||
file: TFile | null,
|
||||
content: string
|
||||
): {
|
||||
exists: boolean;
|
||||
fields: Record<string, unknown>;
|
||||
} {
|
||||
if (file) {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
if (cache?.frontmatter) {
|
||||
return { exists: true, fields: { ...cache.frontmatter } };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to regex parsing when metadataCache is unavailable
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
if (!match) {
|
||||
return { exists: false, fields: {} };
|
||||
}
|
||||
|
||||
const raw = match[1];
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > 0) {
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (key) {
|
||||
fields[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { exists: true, fields };
|
||||
}
|
||||
|
||||
private serializeFrontmatter(fields: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}: [${value.join(', ')}]`);
|
||||
} else if (typeof value === 'string') {
|
||||
lines.push(`${key}: ${value}`);
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
lines.push(`${key}: ${value}`);
|
||||
} else {
|
||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
return `---\n${lines.join('\n')}\n---\n`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// src/agent-modes.ts
|
||||
|
||||
import { AgentMode, OllamaTool } from './types';
|
||||
export { AgentMode };
|
||||
|
||||
/**
|
||||
* Supported agent modes that change the assistant's behavior,
|
||||
* available tools, and system prompt.
|
||||
*/
|
||||
|
||||
export const ALL_AGENT_MODES: AgentMode[] = ['ask', 'edit', 'organize', 'research', 'workflow'];
|
||||
|
||||
export const DEFAULT_AGENT_MODE: AgentMode = 'ask';
|
||||
|
||||
export interface AgentModeConfig {
|
||||
label: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
toolFilter: (tools: OllamaTool[]) => OllamaTool[];
|
||||
requiresPreview: boolean;
|
||||
showModeIndicator: boolean;
|
||||
}
|
||||
|
||||
function filterToolsByName(tools: OllamaTool[], allowed: Set<string>): OllamaTool[] {
|
||||
return tools.filter((t) => allowed.has(t.function.name));
|
||||
}
|
||||
|
||||
const READ_TOOLS = new Set([
|
||||
'read_vault_file',
|
||||
'search_vault_files',
|
||||
'list_vault_tags',
|
||||
'get_vault_stats',
|
||||
]);
|
||||
|
||||
const ORGANIZE_TOOLS = new Set([
|
||||
'read_vault_file',
|
||||
'search_vault_files',
|
||||
'list_vault_tags',
|
||||
'get_vault_stats',
|
||||
'update_frontmatter',
|
||||
'rename_note',
|
||||
'move_note',
|
||||
'insert_link',
|
||||
]);
|
||||
|
||||
const EDIT_TOOLS = new Set([
|
||||
'read_vault_file',
|
||||
'search_vault_files',
|
||||
'list_vault_tags',
|
||||
'get_vault_stats',
|
||||
'create_note',
|
||||
'append_to_note',
|
||||
'replace_note_section',
|
||||
'update_frontmatter',
|
||||
'rename_note',
|
||||
'move_note',
|
||||
'delete_note',
|
||||
'insert_link',
|
||||
]);
|
||||
|
||||
const RESEARCH_TOOLS = READ_TOOLS;
|
||||
|
||||
export const AGENT_MODE_CONFIGS: Record<AgentMode, AgentModeConfig> = {
|
||||
ask: {
|
||||
label: 'Ask',
|
||||
description: 'Answer questions using vault context. Read-only mode.',
|
||||
systemPrompt: `You are a helpful assistant that answers questions using the contents of the user's Obsidian vault.
|
||||
You have access to search and read tools to find relevant information.
|
||||
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
|
||||
Only respond to the user after you have received and analyzed the tool results.
|
||||
Always base your answers on vault content when possible.
|
||||
If you cannot find relevant information, say so clearly.
|
||||
Do not make up facts.`,
|
||||
toolFilter: (tools) => filterToolsByName(tools, READ_TOOLS),
|
||||
requiresPreview: false,
|
||||
showModeIndicator: true,
|
||||
},
|
||||
|
||||
edit: {
|
||||
label: 'Edit',
|
||||
description: 'Create, modify, and organize notes with full editing tools.',
|
||||
systemPrompt: `You are an assistant that helps edit and manage notes in the user's Obsidian vault.
|
||||
You have full access to reading, searching, creating, appending, renaming, moving, and deleting notes.
|
||||
CRITICAL RULE — YOU MUST FOLLOW THIS EXACTLY:
|
||||
1. When you need to interact with the vault, you MUST emit tool_call(s) immediately.
|
||||
2. Do NOT output text like "Let me...", "I will...", "Now I...", "First...", or any description of what you plan to do.
|
||||
3. Do NOT explain your reasoning. Do NOT apologize. Do NOT ask permission.
|
||||
4. Either emit the required tool_call(s) right away, or provide the final answer to the user.
|
||||
5. If tool results are provided to you, synthesize them into a concise final response.
|
||||
|
||||
When editing notes:
|
||||
- Prefer modifying existing content over creating duplicates.
|
||||
- Use the replace_note_section tool to update specific sections.
|
||||
- Use update_frontmatter to manage metadata.
|
||||
- Always confirm destructive actions (deletes, moves) with the user when possible.
|
||||
- Preview changes when the system supports it.`,
|
||||
toolFilter: (tools) => filterToolsByName(tools, EDIT_TOOLS),
|
||||
requiresPreview: true,
|
||||
showModeIndicator: true,
|
||||
},
|
||||
|
||||
organize: {
|
||||
label: 'Organize',
|
||||
description: 'Tag, rename, move, and link notes to keep the vault tidy.',
|
||||
systemPrompt: `You are an assistant that helps organize the user's Obsidian vault.
|
||||
You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links.
|
||||
CRITICAL RULE — YOU MUST FOLLOW THIS EXACTLY:
|
||||
1. When you need to interact with the vault, you MUST emit tool_call(s) immediately.
|
||||
2. Do NOT output text like "Let me...", "I will...", "Now I...", "First...", or any description of what you plan to do.
|
||||
3. Do NOT explain your reasoning. Do NOT apologize. Do NOT ask permission.
|
||||
4. Either emit the required tool_call(s) right away, or provide the final answer to the user.
|
||||
5. If tool results are provided to you, synthesize them into a concise final response.
|
||||
|
||||
When organizing:
|
||||
- Suggest consistent tag vocabularies.
|
||||
- Group related notes by linking them.
|
||||
- Propose folder structures that match the user's existing patterns.
|
||||
- Avoid destructive changes unless explicitly requested.`,
|
||||
toolFilter: (tools) => filterToolsByName(tools, ORGANIZE_TOOLS),
|
||||
requiresPreview: true,
|
||||
showModeIndicator: true,
|
||||
},
|
||||
|
||||
research: {
|
||||
label: 'Research',
|
||||
description: 'Deep vault search and synthesis across multiple notes.',
|
||||
systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault.
|
||||
Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries.
|
||||
CRITICAL RULE — YOU MUST FOLLOW THIS EXACTLY:
|
||||
1. When you need vault information, you MUST emit tool_call(s) immediately.
|
||||
2. Do NOT output text like "Let me...", "I will...", "Now I...", "First...", or any description of what you plan to do.
|
||||
3. Do NOT explain your reasoning. Do NOT apologize. Do NOT ask permission.
|
||||
4. Either emit the required tool_call(s) right away, or provide the final answer to the user.
|
||||
5. If tool results are provided to you, synthesize them into a concise final response.
|
||||
|
||||
Search broadly, read key sources, and cross-reference information.
|
||||
Cite specific notes and quotes where possible.
|
||||
If information is incomplete or contradictory, note it explicitly.`,
|
||||
toolFilter: (tools) => filterToolsByName(tools, RESEARCH_TOOLS),
|
||||
requiresPreview: false,
|
||||
showModeIndicator: true,
|
||||
},
|
||||
|
||||
workflow: {
|
||||
label: 'Workflow',
|
||||
description: 'Execute multi-step workflows via the /workflow command.',
|
||||
systemPrompt: `You are a workflow orchestrator. Users can trigger workflows with the /workflow command.
|
||||
When a user describes a multi-step task, you can suggest using /workflow.
|
||||
Workflows can chain vault searches, LLM calls, tool executions, and formatting steps together.
|
||||
You do not have direct tool access in this mode — workflows handle tool use.`,
|
||||
toolFilter: () => [],
|
||||
requiresPreview: false,
|
||||
showModeIndicator: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display label for an agent mode.
|
||||
*/
|
||||
export function getAgentModeLabel(mode: AgentMode): string {
|
||||
return AGENT_MODE_CONFIGS[mode]?.label ?? mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mode requires action previews for write tools.
|
||||
*/
|
||||
export function modeRequiresPreview(mode: AgentMode): boolean {
|
||||
return AGENT_MODE_CONFIGS[mode]?.requiresPreview ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system prompt for an agent mode.
|
||||
*/
|
||||
export function getSystemPromptForMode(mode: AgentMode): string {
|
||||
return AGENT_MODE_CONFIGS[mode]?.systemPrompt ?? AGENT_MODE_CONFIGS.ask.systemPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tools based on the current agent mode.
|
||||
*/
|
||||
export function filterToolsForMode(tools: OllamaTool[], mode: AgentMode): OllamaTool[] {
|
||||
const config = AGENT_MODE_CONFIGS[mode];
|
||||
if (!config) {
|
||||
return tools;
|
||||
}
|
||||
return config.toolFilter(tools);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { Vault, TFile, Notice, App } from 'obsidian';
|
||||
import { OllamaClient } from './ollama-client';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { Logger } from './utils';
|
||||
|
||||
export interface AutoOrganizeConfig {
|
||||
enabled: boolean;
|
||||
maxTagsPerNote: number;
|
||||
minNoteLength: number;
|
||||
maxNoteLength: number;
|
||||
tagPromptTemplate: string;
|
||||
dryRun: boolean;
|
||||
targetFolder: string;
|
||||
normalizeTags: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_AUTO_ORGANIZE_CONFIG: AutoOrganizeConfig = {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate:
|
||||
'Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}',
|
||||
dryRun: false,
|
||||
targetFolder: '',
|
||||
normalizeTags: true,
|
||||
};
|
||||
|
||||
export interface ProposedTagChange {
|
||||
file: TFile;
|
||||
proposedTags: string[];
|
||||
currentTags?: string[];
|
||||
}
|
||||
|
||||
export interface ProposedLinkChange {
|
||||
file: TFile;
|
||||
relatedNotes: { path: string; title: string; score: number }[];
|
||||
}
|
||||
|
||||
export interface DryRunResult {
|
||||
tagChanges: ProposedTagChange[];
|
||||
linkChanges: ProposedLinkChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a tag string to lowercase, hyphenated, trimmed form.
|
||||
*/
|
||||
export function normalizeTag(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a vocabulary map from existing vault tags.
|
||||
* Maps normalized tag -> preferred canonical form (first seen).
|
||||
*/
|
||||
export function buildTagVocabulary(vault: Vault, app: App): Map<string, string> {
|
||||
const vocab = new Map<string, string>();
|
||||
const files = vault.getMarkdownFiles();
|
||||
for (const file of files) {
|
||||
try {
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cache?.frontmatter;
|
||||
const rawTags: unknown = frontmatter?.tags;
|
||||
const tagList: string[] = [];
|
||||
if (Array.isArray(rawTags)) {
|
||||
tagList.push(...rawTags.map(String));
|
||||
} else if (typeof rawTags === 'string') {
|
||||
tagList.push(
|
||||
...rawTags
|
||||
.split(/[,\n]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
);
|
||||
}
|
||||
for (const tag of tagList) {
|
||||
const norm = normalizeTag(tag);
|
||||
if (norm.length > 0 && !vocab.has(norm)) {
|
||||
vocab.set(norm, tag);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return vocab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a list of tags against a vocabulary.
|
||||
*/
|
||||
export function normalizeTagsAgainstVocabulary(
|
||||
tags: string[],
|
||||
vocab: Map<string, string>
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
const norm = normalizeTag(tag);
|
||||
if (seen.has(norm)) continue;
|
||||
seen.add(norm);
|
||||
// Use canonical form if in vocabulary, otherwise use normalized form
|
||||
const canonical = vocab.get(norm);
|
||||
result.push(canonical ?? norm);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically tags untagged notes using the AI model.
|
||||
*/
|
||||
export class AutoTagger {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
private ollamaClient: OllamaClient;
|
||||
private config: AutoOrganizeConfig;
|
||||
|
||||
constructor(
|
||||
vault: Vault,
|
||||
app: App,
|
||||
ollamaUrl: string,
|
||||
model: string,
|
||||
config: AutoOrganizeConfig
|
||||
) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
this.config = config;
|
||||
this.ollamaClient = new OllamaClient(ollamaUrl, model);
|
||||
}
|
||||
|
||||
updateConfig(config: AutoOrganizeConfig): void {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is inside the target folder.
|
||||
*/
|
||||
private isInTargetFolder(file: TFile): boolean {
|
||||
if (!this.config.targetFolder || this.config.targetFolder.trim().length === 0) {
|
||||
return true;
|
||||
}
|
||||
const target = this.config.targetFolder.replace(/\/$/, '').trim();
|
||||
const fileFolder = file.path.split('/').slice(0, -1).join('/');
|
||||
return fileFolder === target || fileFolder.startsWith(`${target}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a note has meaningful tags using metadataCache.
|
||||
*/
|
||||
private hasTags(file: TFile): boolean {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
if (!cache?.frontmatter) {
|
||||
return false;
|
||||
}
|
||||
const tags: unknown = (cache.frontmatter as Record<string, unknown>)['tags'];
|
||||
if (tags === undefined || tags === null) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(tags)) {
|
||||
return tags.length > 0;
|
||||
}
|
||||
if (typeof tags === 'string') {
|
||||
const trimmed = tags.trim();
|
||||
return trimmed.length > 0 && trimmed !== '[]' && trimmed !== 'null';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all markdown files that lack a `tags` frontmatter field.
|
||||
* Respects targetFolder config.
|
||||
*/
|
||||
getUntaggedNotes(): TFile[] {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const untagged: TFile[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (!this.hasTags(file) && this.isInTargetFolder(file)) {
|
||||
untagged.push(file);
|
||||
}
|
||||
} catch {
|
||||
// skip files that can't be read
|
||||
}
|
||||
}
|
||||
return untagged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tags for a single note using the AI.
|
||||
*/
|
||||
async generateTags(file: TFile): Promise<string[]> {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const length = content.length;
|
||||
|
||||
if (length < this.config.minNoteLength || length > this.config.maxNoteLength) {
|
||||
Logger.info(`Skipping ${file.path}: content length ${length} out of range`, 'auto-tagger');
|
||||
return [];
|
||||
}
|
||||
|
||||
const truncated = content.substring(0, this.config.maxNoteLength);
|
||||
const prompt = this.config.tagPromptTemplate
|
||||
.replace(/\{\{maxTags\}\}/g, String(this.config.maxTagsPerNote))
|
||||
.replace(/\{\{title\}\}/g, file.basename)
|
||||
.replace(/\{\{content\}\}/g, truncated);
|
||||
|
||||
const response = await this.ollamaClient.chat([{ role: 'user', content: prompt }]);
|
||||
|
||||
let tags = this.parseTagResponse(response.content);
|
||||
|
||||
if (this.config.normalizeTags) {
|
||||
const vocab = buildTagVocabulary(this.vault, this.app);
|
||||
tags = normalizeTagsAgainstVocabulary(tags, vocab);
|
||||
}
|
||||
|
||||
Logger.info(`Generated tags for ${file.path}: ${tags.join(', ')}`, 'auto-tagger');
|
||||
return tags;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to generate tags for ${file.path}: ${errorMessage}`, 'auto-tagger');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply tags to a note's frontmatter.
|
||||
*/
|
||||
async applyTags(file: TFile, tags: string[]): Promise<void> {
|
||||
if (tags.length === 0) return;
|
||||
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const hasFrontmatter = !!cache?.frontmatter;
|
||||
|
||||
let newContent: string;
|
||||
if (hasFrontmatter) {
|
||||
// Update existing frontmatter
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (frontmatterMatch) {
|
||||
const frontmatterText = frontmatterMatch[1];
|
||||
const hasTagsLine = /^tags:/m.test(frontmatterText);
|
||||
|
||||
if (hasTagsLine) {
|
||||
// Replace existing tags line
|
||||
const updatedFrontmatter = frontmatterText.replace(
|
||||
/^tags:.*$/m,
|
||||
`tags: ${tags.join(', ')}`
|
||||
);
|
||||
newContent = content.replace(frontmatterMatch[0], `---\n${updatedFrontmatter}\n---\n`);
|
||||
} else {
|
||||
// Add tags line to existing frontmatter
|
||||
const updatedFrontmatter = `tags: ${tags.join(', ')}\n${frontmatterText}`;
|
||||
newContent = content.replace(frontmatterMatch[0], `---\n${updatedFrontmatter}\n---\n`);
|
||||
}
|
||||
} else {
|
||||
// MetadataCache says frontmatter exists but regex didn't find it — add new block
|
||||
newContent = `---\ntags: ${tags.join(', ')}\n---\n\n${content}`;
|
||||
}
|
||||
} else {
|
||||
// Add new frontmatter block
|
||||
newContent = `---\ntags: ${tags.join(', ')}\n---\n\n${content}`;
|
||||
}
|
||||
|
||||
await this.vault.modify(file, newContent);
|
||||
Logger.info(`Tagged ${file.path} with: ${tags.join(', ')}`, 'auto-tagger');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to apply tags to ${file.path}: ${errorMessage}`, 'auto-tagger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-tagging on all untagged notes.
|
||||
* If dryRun is enabled, returns proposed changes without applying.
|
||||
*/
|
||||
async run(): Promise<{ tagged: number; skipped: number; dryRun?: ProposedTagChange[] }> {
|
||||
if (!this.config.enabled) {
|
||||
new Notice('Auto-tagging is disabled in settings.');
|
||||
return { tagged: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const untagged = this.getUntaggedNotes();
|
||||
if (untagged.length === 0) {
|
||||
new Notice('No untagged notes found.');
|
||||
return { tagged: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
if (this.config.dryRun) {
|
||||
new Notice(`Dry-run: evaluating ${untagged.length} notes...`);
|
||||
const proposals: ProposedTagChange[] = [];
|
||||
let skipped = 0;
|
||||
for (const file of untagged) {
|
||||
const tags = await this.generateTags(file);
|
||||
if (tags.length > 0) {
|
||||
proposals.push({ file, proposedTags: tags });
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
new Notice(`Dry-run complete: ${proposals.length} proposed tag changes, ${skipped} skipped.`);
|
||||
return { tagged: proposals.length, skipped, dryRun: proposals };
|
||||
}
|
||||
|
||||
new Notice(`Auto-tagging ${untagged.length} notes...`);
|
||||
let tagged = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of untagged) {
|
||||
const tags = await this.generateTags(file);
|
||||
if (tags.length > 0) {
|
||||
await this.applyTags(file, tags);
|
||||
tagged++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
// Small delay to avoid overloading Ollama
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
new Notice(`Auto-tagging complete: ${tagged} tagged, ${skipped} skipped.`);
|
||||
return { tagged, skipped };
|
||||
}
|
||||
|
||||
private parseTagResponse(response: string): string[] {
|
||||
return response
|
||||
.split(/[,\n]+/)
|
||||
.map((t) => t.trim().replace(/^#+/, '').replace(/['"]+/g, ''))
|
||||
.filter((t) => t.length > 0 && t.length < 50)
|
||||
.slice(0, this.config.maxTagsPerNote);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically adds wiki-links to related notes based on semantic similarity.
|
||||
*/
|
||||
export class AutoLinker {
|
||||
private vault: Vault;
|
||||
private vaultIndexer: VaultIndexer;
|
||||
private config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number };
|
||||
private targetFolder: string;
|
||||
|
||||
constructor(
|
||||
vault: Vault,
|
||||
vaultIndexer: VaultIndexer,
|
||||
config: { enabled: boolean; maxLinksPerNote: number; similarityThreshold: number },
|
||||
targetFolder: string = ''
|
||||
) {
|
||||
this.vault = vault;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
this.config = config;
|
||||
this.targetFolder = targetFolder;
|
||||
}
|
||||
|
||||
updateConfig(config: {
|
||||
enabled: boolean;
|
||||
maxLinksPerNote: number;
|
||||
similarityThreshold: number;
|
||||
targetFolder?: string;
|
||||
}): void {
|
||||
this.config = config;
|
||||
if (typeof config.targetFolder === 'string') {
|
||||
this.targetFolder = config.targetFolder;
|
||||
}
|
||||
}
|
||||
|
||||
setTargetFolder(folder: string): void {
|
||||
this.targetFolder = folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is inside the target folder.
|
||||
*/
|
||||
private isInTargetFolder(file: TFile): boolean {
|
||||
if (!this.targetFolder || this.targetFolder.trim().length === 0) {
|
||||
return true;
|
||||
}
|
||||
const target = this.targetFolder.replace(/\/$/, '').trim();
|
||||
const fileFolder = file.path.split('/').slice(0, -1).join('/');
|
||||
return fileFolder === target || fileFolder.startsWith(`${target}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find related notes for a given file using semantic search.
|
||||
*/
|
||||
async findRelatedNotes(file: TFile): Promise<{ path: string; title: string; score: number }[]> {
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
const truncated = content.substring(0, 2000);
|
||||
|
||||
const results = await this.vaultIndexer.searchVault(
|
||||
truncated,
|
||||
this.config.maxLinksPerNote + 5
|
||||
);
|
||||
|
||||
// Filter out self and low-similarity results
|
||||
return results
|
||||
.filter((r) => r.path !== file.path && r.score >= this.config.similarityThreshold)
|
||||
.slice(0, this.config.maxLinksPerNote)
|
||||
.map((r) => ({ path: r.path, title: r.title, score: r.score }));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to find related notes for ${file.path}: ${errorMessage}`, 'auto-linker');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a "Related Notes" section to a note if it doesn't already exist.
|
||||
*/
|
||||
async addRelatedLinks(file: TFile, related: { path: string; title: string }[]): Promise<void> {
|
||||
if (related.length === 0) return;
|
||||
|
||||
try {
|
||||
const content = await this.vault.read(file);
|
||||
|
||||
// Skip if already has a Related Notes section
|
||||
if (/^## Related Notes/m.test(content)) {
|
||||
Logger.info(`Skipping ${file.path}: already has Related Notes section`, 'auto-linker');
|
||||
return;
|
||||
}
|
||||
|
||||
const links = related
|
||||
.map((r) => `- [[${r.title}|${r.path.replace(/\.md$/, '')}]]`)
|
||||
.join('\n');
|
||||
const section = `\n\n## Related Notes\n\n${links}\n`;
|
||||
|
||||
await this.vault.modify(file, content + section);
|
||||
Logger.info(`Added ${related.length} related links to ${file.path}`, 'auto-linker');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to add links to ${file.path}: ${errorMessage}`, 'auto-linker');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-linking on all notes.
|
||||
* If dryRun is enabled, returns proposed changes without applying.
|
||||
*/
|
||||
async run(
|
||||
dryRun = false
|
||||
): Promise<{ linked: number; skipped: number; dryRun?: ProposedLinkChange[] }> {
|
||||
if (!this.config.enabled) {
|
||||
new Notice('Auto-linking is disabled in settings.');
|
||||
return { linked: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const files = this.vault.getMarkdownFiles().filter((f) => this.isInTargetFolder(f));
|
||||
|
||||
if (dryRun) {
|
||||
new Notice(`Dry-run: evaluating ${files.length} notes for links...`);
|
||||
const proposals: ProposedLinkChange[] = [];
|
||||
let skipped = 0;
|
||||
for (const file of files) {
|
||||
const related = await this.findRelatedNotes(file);
|
||||
if (related.length > 0) {
|
||||
proposals.push({ file, relatedNotes: related });
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
new Notice(`Dry-run complete: ${proposals.length} proposed link changes.`);
|
||||
return { linked: proposals.length, skipped, dryRun: proposals };
|
||||
}
|
||||
|
||||
new Notice(`Auto-linking ${files.length} notes...`);
|
||||
|
||||
let linked = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const related = await this.findRelatedNotes(file);
|
||||
if (related.length > 0) {
|
||||
await this.addRelatedLinks(file, related);
|
||||
linked++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
// Small delay between files
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
new Notice(`Auto-linking complete: ${linked} linked, ${skipped} skipped.`);
|
||||
return { linked, skipped };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// src/chat-history.ts
|
||||
|
||||
import { ChatSession, ChatHistoryData, ChatMessage, AgentMode } from './types';
|
||||
|
||||
const DEFAULT_MAX_SESSIONS = 50;
|
||||
|
||||
export function createDefaultChatHistoryData(): ChatHistoryData {
|
||||
return {
|
||||
sessions: [],
|
||||
activeSessionId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class ChatHistoryManager {
|
||||
private data: ChatHistoryData;
|
||||
private maxSessions: number;
|
||||
|
||||
constructor(data?: ChatHistoryData, maxSessions: number = DEFAULT_MAX_SESSIONS) {
|
||||
this.data = data ?? createDefaultChatHistoryData();
|
||||
this.maxSessions = maxSessions;
|
||||
}
|
||||
|
||||
getData(): ChatHistoryData {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
getSessions(): ChatSession[] {
|
||||
return [...this.data.sessions];
|
||||
}
|
||||
|
||||
getSession(id: string): ChatSession | undefined {
|
||||
return this.data.sessions.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
getActiveSessionId(): string | undefined {
|
||||
return this.data.activeSessionId;
|
||||
}
|
||||
|
||||
setActiveSessionId(id: string | undefined): void {
|
||||
this.data.activeSessionId = id;
|
||||
}
|
||||
|
||||
createSession(agentMode: AgentMode): ChatSession {
|
||||
const session: ChatSession = {
|
||||
id: crypto.randomUUID?.() ?? `session-${Date.now()}-${Math.random()}`,
|
||||
title: 'New Chat',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: [],
|
||||
agentMode,
|
||||
};
|
||||
this.data.sessions.unshift(session);
|
||||
this.data.activeSessionId = session.id;
|
||||
this.trimSessions();
|
||||
return session;
|
||||
}
|
||||
|
||||
updateSession(id: string, updates: Partial<ChatSession>): ChatSession | undefined {
|
||||
const index = this.data.sessions.findIndex((s) => s.id === id);
|
||||
if (index === -1) return undefined;
|
||||
const session = this.data.sessions[index];
|
||||
const updated = { ...session, ...updates, updatedAt: Date.now() };
|
||||
this.data.sessions[index] = updated;
|
||||
// Move to top so most recent sessions appear first
|
||||
this.data.sessions.splice(index, 1);
|
||||
this.data.sessions.unshift(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
updateSessionMessages(id: string, messages: ChatMessage[]): ChatSession | undefined {
|
||||
const session = this.getSession(id);
|
||||
if (!session) return undefined;
|
||||
const title = this.deriveTitle(messages);
|
||||
return this.updateSession(id, { messages: [...messages], title });
|
||||
}
|
||||
|
||||
deleteSession(id: string): boolean {
|
||||
const initialLength = this.data.sessions.length;
|
||||
this.data.sessions = this.data.sessions.filter((s) => s.id !== id);
|
||||
if (this.data.activeSessionId === id) {
|
||||
this.data.activeSessionId = undefined;
|
||||
}
|
||||
return this.data.sessions.length < initialLength;
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.data.sessions = [];
|
||||
this.data.activeSessionId = undefined;
|
||||
}
|
||||
|
||||
private trimSessions(): void {
|
||||
if (this.data.sessions.length > this.maxSessions) {
|
||||
const removed = this.data.sessions.splice(this.maxSessions);
|
||||
if (this.data.activeSessionId && removed.some((s) => s.id === this.data.activeSessionId)) {
|
||||
this.data.activeSessionId = this.data.sessions[0]?.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private deriveTitle(messages: ChatMessage[]): string {
|
||||
const firstUser = messages.find((m) => m.role === 'user');
|
||||
if (!firstUser) return 'New Chat';
|
||||
const text = firstUser.content.trim();
|
||||
if (!text) return 'New Chat';
|
||||
// Limit to ~40 chars with ellipsis
|
||||
return text.length > 40 ? text.slice(0, 40) + '…' : text;
|
||||
}
|
||||
}
|
||||
+1593
-109
File diff suppressed because it is too large
Load Diff
+41
-2
@@ -1,9 +1,13 @@
|
||||
export const DEFAULT_SETTINGS = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
model: 'llama3',
|
||||
vaultSearchLimit: 3,
|
||||
chatModel: 'deepseek-v4-flash',
|
||||
agentModel: 'glm-5.1',
|
||||
model: 'deepseek-v4-flash',
|
||||
vaultSearchLimit: 5,
|
||||
maxMessageHistory: 50,
|
||||
maxContextLength: 8000,
|
||||
lastIndexTime: 0,
|
||||
agentMode: 'ask' as const,
|
||||
cacheConfig: {
|
||||
enabled: false,
|
||||
similarityThreshold: 0.85,
|
||||
@@ -11,4 +15,39 @@ export const DEFAULT_SETTINGS = {
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
vaultIndexConfig: {
|
||||
enabled: false,
|
||||
similarityThreshold: 0.75,
|
||||
collectionName: 'ollama_vault_index',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
autoTagConfig: {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate:
|
||||
'Given the following note, suggest {{maxTags}} relevant, concise tags that describe its content.\n\nReturn ONLY a comma-separated list of tags (no quotes, no numbering, no explanations).\n\nTitle: {{title}}\n\nContent:\n{{content}}',
|
||||
dryRun: false,
|
||||
targetFolder: '',
|
||||
normalizeTags: true,
|
||||
},
|
||||
autoLinkConfig: {
|
||||
enabled: false,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.6,
|
||||
targetFolder: '',
|
||||
dryRun: false,
|
||||
},
|
||||
structuredMemoryConfig: {
|
||||
enabled: true,
|
||||
maxSummaries: 10,
|
||||
maxPreferences: 20,
|
||||
maxFacts: 50,
|
||||
},
|
||||
toolTelemetryConfig: {
|
||||
enabled: true,
|
||||
maxEntries: 100,
|
||||
},
|
||||
};
|
||||
|
||||
+19
-20
@@ -8,6 +8,10 @@ export interface ConversationState {
|
||||
longTermContext: OllamaMessage[];
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are an assistant that can help answer questions using the contents of a vault.
|
||||
When a user asks for information about their vault, you MUST call the search_vault_files or read_vault_file tool to find the answer.
|
||||
Do not say you will search or read files — immediately emit the tool_call.`;
|
||||
|
||||
export class ConversationStateManager {
|
||||
private shortTermContext: OllamaMessage[] = [];
|
||||
private mediumTermContext: OllamaMessage[] = [];
|
||||
@@ -15,15 +19,12 @@ export class ConversationStateManager {
|
||||
private maxShortTermTurns: number = 10;
|
||||
private maxMediumTermMessages: number = 20;
|
||||
|
||||
constructor() {
|
||||
constructor(initialSystemPrompt?: string) {
|
||||
// Initialize with default system context
|
||||
this.longTermContext = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are an assistant that can help answer questions using the contents of a vault.
|
||||
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
||||
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
||||
Only use the tools if you need to access vault content that is not already in the context.`,
|
||||
content: initialSystemPrompt ?? DEFAULT_SYSTEM_PROMPT,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -60,17 +61,18 @@ export class ConversationStateManager {
|
||||
* Sets the user's persona or core knowledge as long-term context
|
||||
* @param personaContent The persona or core knowledge content
|
||||
*/
|
||||
setPersona(personaContent: string): void {
|
||||
// Remove any existing persona messages
|
||||
this.longTermContext = this.longTermContext.filter(
|
||||
(msg) =>
|
||||
msg.role !== 'system' ||
|
||||
!msg.content.includes(
|
||||
'You are an assistant that can help answer questions using the contents of a vault'
|
||||
)
|
||||
);
|
||||
setSystemPrompt(systemPrompt: string): void {
|
||||
// Replace all existing system messages with the new system prompt
|
||||
this.longTermContext = this.longTermContext.filter((msg) => msg.role !== 'system');
|
||||
this.longTermContext.unshift({
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new persona
|
||||
setPersona(personaContent: string): void {
|
||||
// Replace all existing system messages with the new persona
|
||||
this.longTermContext = this.longTermContext.filter((msg) => msg.role !== 'system');
|
||||
this.longTermContext.push({
|
||||
role: 'system',
|
||||
content: personaContent,
|
||||
@@ -117,16 +119,13 @@ export class ConversationStateManager {
|
||||
/**
|
||||
* Clears all conversation context
|
||||
*/
|
||||
clear(): void {
|
||||
clear(systemPrompt?: string): void {
|
||||
this.shortTermContext = [];
|
||||
this.mediumTermContext = [];
|
||||
this.longTermContext = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are an assistant that can help answer questions using the contents of a vault.
|
||||
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
|
||||
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
|
||||
Only use the tools if you need to access vault content that is not already in the context.`,
|
||||
content: systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ErrorHandler {
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
return 'Connection error. Please check if Ollama is running.';
|
||||
case ErrorType.API_ERROR:
|
||||
return `API error: ${error.message}`;
|
||||
return error.message;
|
||||
case ErrorType.VALIDATION_ERROR:
|
||||
return this.getUserFriendlyValidationMessage(error);
|
||||
case ErrorType.STREAMING_ERROR:
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface ExtractedContent {
|
||||
firstParagraph?: string;
|
||||
}
|
||||
|
||||
interface CachedMetadataLike {
|
||||
frontmatter?: Record<string, unknown>;
|
||||
headings?: Array<{
|
||||
heading: string;
|
||||
level: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts raw content from a vault file including:
|
||||
* - Markdown content
|
||||
@@ -30,44 +38,65 @@ export interface ExtractedContent {
|
||||
* - First paragraph
|
||||
*/
|
||||
export class ContentExtractor {
|
||||
extractFromFile(file: VaultFile, content: string): ExtractedContent {
|
||||
extractFromFile(file: VaultFile, content: string, cache?: CachedMetadataLike): ExtractedContent {
|
||||
const frontmatter: Frontmatter = {};
|
||||
const headings: string[] = [];
|
||||
const embeddedCodeBlocks: string[] = [];
|
||||
let firstParagraph: string | undefined;
|
||||
|
||||
// Extract frontmatter
|
||||
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
||||
if (frontmatterMatch) {
|
||||
try {
|
||||
const frontmatterContent = frontmatterMatch[1];
|
||||
const lines = frontmatterContent.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
if (!key) continue;
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key.trim() === 'title') {
|
||||
if (value) {
|
||||
frontmatter.title = value;
|
||||
}
|
||||
} else if (key.trim() === 'tags') {
|
||||
if (value) {
|
||||
frontmatter.tags = value;
|
||||
}
|
||||
} else {
|
||||
// Store other frontmatter fields as-is
|
||||
frontmatter[key.trim()] = value;
|
||||
// Extract frontmatter from metadataCache if available, otherwise fall back to regex
|
||||
if (cache?.frontmatter) {
|
||||
const fm = cache.frontmatter;
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
if (key === 'title' && typeof value === 'string') {
|
||||
frontmatter.title = value;
|
||||
} else if (key === 'tags') {
|
||||
if (Array.isArray(value)) {
|
||||
frontmatter.tags = value.join(', ');
|
||||
} else if (typeof value === 'string') {
|
||||
frontmatter.tags = value;
|
||||
}
|
||||
} else {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const frontmatterMatch = content.match(/^---(.*?)---/s);
|
||||
if (frontmatterMatch) {
|
||||
try {
|
||||
const frontmatterContent = frontmatterMatch[1];
|
||||
const lines = frontmatterContent.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
if (!key) continue;
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key.trim() === 'title') {
|
||||
if (value) {
|
||||
frontmatter.title = value;
|
||||
}
|
||||
} else if (key.trim() === 'tags') {
|
||||
if (value) {
|
||||
frontmatter.tags = value;
|
||||
}
|
||||
} else {
|
||||
// Store other frontmatter fields as-is
|
||||
frontmatter[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If frontmatter parsing fails, continue with empty frontmatter
|
||||
}
|
||||
} catch {
|
||||
// If frontmatter parsing fails, continue with empty frontmatter
|
||||
}
|
||||
}
|
||||
|
||||
// Extract headings
|
||||
const headingMatches = content.match(/^#{1,6} (.*?)$/gm);
|
||||
if (headingMatches) {
|
||||
headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, '')));
|
||||
// Extract headings from metadataCache if available, otherwise fall back to regex
|
||||
if (cache?.headings) {
|
||||
headings.push(...cache.headings.map((h) => h.heading));
|
||||
} else {
|
||||
const headingMatches = content.match(/^#{1,6} (.*?)$/gm);
|
||||
if (headingMatches) {
|
||||
headings.push(...headingMatches.map((h: string) => h.replace(/^#{1,6} /, '')));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract embedded code blocks
|
||||
|
||||
@@ -19,40 +19,64 @@ export class ContentVectorizer {
|
||||
constructor(config: VectorizationConfig, fetchFn?: typeof fetch) {
|
||||
this.model = config.model;
|
||||
this.ollamaUrl = config.ollamaUrl;
|
||||
this.fetchFn = fetchFn ?? fetch;
|
||||
this.fetchFn = fetchFn ?? ((url, init) => fetch(url, init));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates embeddings for a content chunk
|
||||
* Generates embeddings for a content chunk with retry logic
|
||||
*/
|
||||
async vectorize(chunk: ContentChunk): Promise<number[]> {
|
||||
try {
|
||||
const prompt = this.createPrompt(chunk);
|
||||
const prompt = this.createPrompt(chunk);
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
const response = await this.fetchFn(`${this.ollamaUrl}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
}),
|
||||
});
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
const delay = baseDelay * Math.pow(2, attempt - 1);
|
||||
Logger.info(
|
||||
`Retrying embedding (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`,
|
||||
'indexing-pipeline'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Embedding failed with status ${response.status}`);
|
||||
const response = await this.fetchFn(`${this.ollamaUrl}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Embedding failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
if (!this.isEmbeddingResponse(data)) {
|
||||
throw new Error('Invalid embedding response');
|
||||
}
|
||||
|
||||
return data.embedding;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(
|
||||
`Embedding attempt ${attempt + 1} failed: ${errorMessage}`,
|
||||
'indexing-pipeline'
|
||||
);
|
||||
if (attempt === maxRetries - 1) {
|
||||
Logger.warn(
|
||||
`Failed to generate embedding after ${maxRetries} attempts`,
|
||||
'indexing-pipeline'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
if (!this.isEmbeddingResponse(data)) {
|
||||
throw new Error('Invalid embedding response');
|
||||
}
|
||||
|
||||
return data.embedding;
|
||||
} catch (error) {
|
||||
// Return empty array on failure to maintain compatibility
|
||||
Logger.warn(`Failed to generate embedding: ${String(error)}`, 'indexing-pipeline');
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private isEmbeddingResponse(data: unknown): data is { embedding: number[] } {
|
||||
@@ -68,13 +92,13 @@ export class ContentVectorizer {
|
||||
* Creates a prompt from content chunk for embedding
|
||||
*/
|
||||
private createPrompt(chunk: ContentChunk): string {
|
||||
// Combine important elements for embedding
|
||||
// Combine important elements for embedding, keeping it concise
|
||||
// to avoid exceeding the embedding model's context window
|
||||
const parts = [
|
||||
chunk.title,
|
||||
chunk.firstParagraph,
|
||||
chunk.content.substring(0, 1000), // Limit content to avoid long prompts
|
||||
chunk.headings.join(' '),
|
||||
JSON.stringify(chunk.frontmatter),
|
||||
chunk.content.substring(0, 500), // Limit content to avoid long prompts
|
||||
chunk.headings.slice(0, 5).join(' '), // Limit headings
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join('\n\n');
|
||||
|
||||
+921
-17
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
// src/note-context-builder.ts
|
||||
|
||||
import { Vault, TFile, App, MarkdownView } from 'obsidian';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { VaultIndexEntry } from './types';
|
||||
import { Logger } from './utils';
|
||||
|
||||
export interface NoteContextOptions {
|
||||
includeOpenNote?: boolean;
|
||||
includeSelectedText?: boolean;
|
||||
includeBacklinks?: boolean;
|
||||
includeOutlinks?: boolean;
|
||||
includeRelated?: boolean;
|
||||
maxRelatedNotes?: number;
|
||||
scopeToExplicitNotes?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteContext {
|
||||
explicitMentions: VaultIndexEntry[];
|
||||
openNote?: VaultIndexEntry;
|
||||
selectedText?: string;
|
||||
backlinks: VaultIndexEntry[];
|
||||
outlinks: VaultIndexEntry[];
|
||||
relatedNotes: VaultIndexEntry[];
|
||||
searchResults: VaultIndexEntry[];
|
||||
}
|
||||
|
||||
export class NoteContextBuilder {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
private vaultIndexer: VaultIndexer;
|
||||
|
||||
constructor(vault: Vault, app: App, vaultIndexer: VaultIndexer) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts wikilink mentions like [[Note Title]] from a message.
|
||||
*/
|
||||
extractExplicitMentions(message: string): string[] {
|
||||
const mentions: string[] = [];
|
||||
const wikiLinkRegex = /\[\[(.+?)\]\]/g;
|
||||
let match;
|
||||
while ((match = wikiLinkRegex.exec(message)) !== null) {
|
||||
const title = match[1].split('|')[0].trim(); // Strip alias
|
||||
mentions.push(title);
|
||||
}
|
||||
return [...new Set(mentions)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects scope commands in the user message.
|
||||
* Returns 'explicit' if user says "use only this note" or similar.
|
||||
* Returns 'related' if user says "include related notes" or similar.
|
||||
* Returns 'default' otherwise.
|
||||
*/
|
||||
detectScopeIntent(message: string): 'explicit' | 'related' | 'default' {
|
||||
const lower = message.toLowerCase();
|
||||
if (
|
||||
lower.includes('use only this note') ||
|
||||
lower.includes('only this note') ||
|
||||
lower.includes('just this note') ||
|
||||
lower.includes('use only the current note')
|
||||
) {
|
||||
return 'explicit';
|
||||
}
|
||||
if (
|
||||
lower.includes('include related notes') ||
|
||||
lower.includes('include related') ||
|
||||
lower.includes('neighboring notes') ||
|
||||
lower.includes('linked notes') ||
|
||||
lower.includes('context around')
|
||||
) {
|
||||
return 'related';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently active note entry.
|
||||
*/
|
||||
private async getOpenNote(): Promise<VaultIndexEntry | undefined> {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
return undefined;
|
||||
}
|
||||
return this.fileToIndexEntry(activeFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets selected text from the active markdown editor.
|
||||
*/
|
||||
private getSelectedText(): string | undefined {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView) {
|
||||
return undefined;
|
||||
}
|
||||
const editor = activeView.editor;
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
const selection = editor.getSelection().trim();
|
||||
return selection.length > 0 ? selection : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a note title or path to a TFile.
|
||||
*/
|
||||
private resolveNote(titleOrPath: string): TFile | null {
|
||||
const isFile = (f: unknown): f is TFile =>
|
||||
!!f && typeof f === 'object' && 'path' in f && 'basename' in f;
|
||||
|
||||
// Try exact path first
|
||||
const byPath = this.vault.getAbstractFileByPath(titleOrPath);
|
||||
if (isFile(byPath)) {
|
||||
return byPath;
|
||||
}
|
||||
|
||||
// Try with .md extension
|
||||
const withExtension = titleOrPath.endsWith('.md') ? titleOrPath : `${titleOrPath}.md`;
|
||||
const byPathExt = this.vault.getAbstractFileByPath(withExtension);
|
||||
if (isFile(byPathExt)) {
|
||||
return byPathExt;
|
||||
}
|
||||
|
||||
// Try by basename
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
return files.find((f) => f.basename === titleOrPath) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file content and builds a VaultIndexEntry using metadataCache.
|
||||
*/
|
||||
private async fileToIndexEntry(file: TFile): Promise<VaultIndexEntry> {
|
||||
try {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
|
||||
// Resolve title from metadataCache: frontmatter > first heading > basename
|
||||
let title = file.basename;
|
||||
const frontmatter = cache?.frontmatter
|
||||
? (cache.frontmatter as unknown as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (
|
||||
frontmatter?.title &&
|
||||
typeof frontmatter.title === 'string'
|
||||
) {
|
||||
title = frontmatter.title;
|
||||
} else if (cache?.headings && cache.headings.length > 0) {
|
||||
title = cache.headings[0].heading;
|
||||
}
|
||||
|
||||
// Resolve tags from metadataCache
|
||||
let tags: string | undefined;
|
||||
const frontmatterTags = frontmatter?.tags;
|
||||
if (Array.isArray(frontmatterTags)) {
|
||||
tags = frontmatterTags.join(', ');
|
||||
} else if (typeof frontmatterTags === 'string') {
|
||||
tags = frontmatterTags;
|
||||
}
|
||||
|
||||
const body = content.replace(/^---\n[\s\S]*?\n---\n/, '').slice(0, 500);
|
||||
return {
|
||||
path: file.path,
|
||||
title,
|
||||
content: body,
|
||||
score: 1,
|
||||
tags,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to read ${file.path}: ${errorMessage}`, 'note-context');
|
||||
return {
|
||||
path: file.path,
|
||||
title: file.basename,
|
||||
content: '',
|
||||
score: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets backlinks for a file using Obsidian's metadataCache.
|
||||
*/
|
||||
private getBacklinks(file: TFile): TFile[] {
|
||||
interface CacheWithResolvedLinks {
|
||||
resolvedLinks?: Record<string, Record<string, number>>;
|
||||
}
|
||||
const metadataCache = this.app.metadataCache as CacheWithResolvedLinks;
|
||||
const resolvedLinks = metadataCache.resolvedLinks ?? {};
|
||||
const backlinks: TFile[] = [];
|
||||
for (const sourcePath of Object.keys(resolvedLinks)) {
|
||||
const targets = resolvedLinks[sourcePath];
|
||||
if (targets && targets[file.path]) {
|
||||
const sourceFile = this.vault.getAbstractFileByPath(sourcePath);
|
||||
if (sourceFile && typeof sourceFile === 'object' && 'path' in sourceFile) {
|
||||
backlinks.push(sourceFile as TFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
return backlinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets outlinks (forward links) for a file using Obsidian's metadataCache.
|
||||
*/
|
||||
private getOutlinks(file: TFile): TFile[] {
|
||||
const cache = this.app.metadataCache.getCache(file.path);
|
||||
if (!cache?.links) {
|
||||
return [];
|
||||
}
|
||||
const outlinks: TFile[] = [];
|
||||
for (const link of cache.links) {
|
||||
const targetPath = link.link;
|
||||
// Resolve relative or bare links
|
||||
const resolved = this.resolveNote(targetPath);
|
||||
if (resolved) {
|
||||
outlinks.push(resolved);
|
||||
}
|
||||
}
|
||||
return [...new Set(outlinks.map((f) => f.path))]
|
||||
.map((p) => this.vault.getAbstractFileByPath(p))
|
||||
.filter((f): f is TFile => !!f && typeof f === 'object' && 'path' in f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full note context for a user message.
|
||||
*/
|
||||
async buildContext(
|
||||
message: string,
|
||||
searchLimit: number,
|
||||
options: NoteContextOptions = {}
|
||||
): Promise<NoteContext> {
|
||||
const scope = this.detectScopeIntent(message);
|
||||
|
||||
const explicitTitles = this.extractExplicitMentions(message);
|
||||
const explicitNotes: VaultIndexEntry[] = [];
|
||||
for (const title of explicitTitles) {
|
||||
const file = this.resolveNote(title);
|
||||
if (file) {
|
||||
explicitNotes.push(await this.fileToIndexEntry(file));
|
||||
}
|
||||
}
|
||||
|
||||
let openNote: VaultIndexEntry | undefined;
|
||||
let selectedText: string | undefined;
|
||||
const backlinks: VaultIndexEntry[] = [];
|
||||
const outlinks: VaultIndexEntry[] = [];
|
||||
const relatedNotes: VaultIndexEntry[] = [];
|
||||
let searchResults: VaultIndexEntry[] = [];
|
||||
|
||||
// Get open note and selected text
|
||||
if (scope !== 'explicit' || explicitNotes.length === 0) {
|
||||
openNote = await this.getOpenNote();
|
||||
if (options.includeSelectedText !== false) {
|
||||
selectedText = this.getSelectedText();
|
||||
}
|
||||
}
|
||||
|
||||
// Get backlinks / outlinks for open note
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (
|
||||
activeFile &&
|
||||
(scope === 'related' || options.includeBacklinks || options.includeOutlinks)
|
||||
) {
|
||||
if (options.includeBacklinks !== false) {
|
||||
const backFiles = this.getBacklinks(activeFile);
|
||||
for (const f of backFiles.slice(0, options.maxRelatedNotes ?? 10)) {
|
||||
backlinks.push(await this.fileToIndexEntry(f));
|
||||
}
|
||||
}
|
||||
if (options.includeOutlinks !== false) {
|
||||
const outFiles = this.getOutlinks(activeFile);
|
||||
for (const f of outFiles.slice(0, options.maxRelatedNotes ?? 10)) {
|
||||
outlinks.push(await this.fileToIndexEntry(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine backlinks + outlinks into related
|
||||
if (scope === 'related' || options.includeRelated) {
|
||||
const relatedPaths = new Set<string>();
|
||||
for (const n of [...backlinks, ...outlinks]) {
|
||||
if (!relatedPaths.has(n.path)) {
|
||||
relatedPaths.add(n.path);
|
||||
relatedNotes.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vault search
|
||||
if (scope !== 'explicit') {
|
||||
const searchQuery = this.sanitizeSearchQuery(message);
|
||||
searchResults = await this.vaultIndexer.searchVault(searchQuery, searchLimit);
|
||||
} else if (explicitNotes.length > 0) {
|
||||
// If explicit scope and we have explicit notes, just use those
|
||||
searchResults = explicitNotes;
|
||||
}
|
||||
|
||||
return {
|
||||
explicitMentions: explicitNotes,
|
||||
openNote,
|
||||
selectedText,
|
||||
backlinks,
|
||||
outlinks,
|
||||
relatedNotes,
|
||||
searchResults,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a NoteContext into a string for the LLM prompt.
|
||||
*/
|
||||
formatContext(context: NoteContext, maxLength: number): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (context.selectedText) {
|
||||
parts.push(`Selected text from current note:\n${context.selectedText}`);
|
||||
}
|
||||
|
||||
if (context.openNote) {
|
||||
parts.push(`Current open note: ${context.openNote.title} (${context.openNote.path})`);
|
||||
if (context.openNote.tags) {
|
||||
parts.push(`Tags: ${context.openNote.tags}`);
|
||||
}
|
||||
parts.push(context.openNote.content);
|
||||
}
|
||||
|
||||
if (context.explicitMentions.length > 0) {
|
||||
parts.push('Explicitly mentioned notes:');
|
||||
for (const note of context.explicitMentions) {
|
||||
parts.push(`- ${note.title} (${note.path})`);
|
||||
if (note.tags) parts.push(` Tags: ${note.tags}`);
|
||||
parts.push(note.content.slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
if (context.relatedNotes.length > 0) {
|
||||
parts.push('Related notes (backlinks + outlinks):');
|
||||
for (const note of context.relatedNotes) {
|
||||
parts.push(`- ${note.title} (${note.path})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.searchResults.length > 0) {
|
||||
parts.push('Vault search results:');
|
||||
for (const note of context.searchResults) {
|
||||
parts.push(`- ${note.title} (${note.path})`);
|
||||
if (note.tags) parts.push(` Tags: ${note.tags}`);
|
||||
parts.push(note.content.slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
let result = parts.join('\n\n');
|
||||
if (result.length > maxLength) {
|
||||
result = result.slice(0, maxLength) + '\n... [truncated]';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes wikilinks and command phrases to get a clean search query.
|
||||
*/
|
||||
private sanitizeSearchQuery(message: string): string {
|
||||
return message
|
||||
.replace(/\[\[.+?\]\]/g, '')
|
||||
.replace(/use only this note/gi, '')
|
||||
.replace(/include related notes/gi, '')
|
||||
.replace(/include related/gi, '')
|
||||
.replace(/neighboring notes/gi, '')
|
||||
.replace(/linked notes/gi, '')
|
||||
.replace(/context around/gi, '')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
+101
-16
@@ -8,6 +8,9 @@ import { SemanticCacheService } from './semantic-cache';
|
||||
interface OllamaChatResponse {
|
||||
message?: Partial<OllamaMessage>;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
@@ -22,11 +25,10 @@ export class OllamaClient {
|
||||
constructor(baseURL: string, model: string, fetchFn?: typeof fetch, cacheConfig?: CacheConfig) {
|
||||
this.baseURL = baseURL;
|
||||
this.model = model;
|
||||
this.fetchFn = fetchFn ?? fetch;
|
||||
this.fetchFn = fetchFn ?? ((url, init) => fetch(url, init));
|
||||
|
||||
if (cacheConfig?.enabled) {
|
||||
this.cacheService = new SemanticCacheService(baseURL, cacheConfig);
|
||||
void this.cacheService.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +149,12 @@ export class OllamaClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(
|
||||
`Model "${this.model}" not found. Run \`ollama pull ${this.model}\` first.`,
|
||||
404
|
||||
);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
@@ -155,8 +163,10 @@ export class OllamaClient {
|
||||
}
|
||||
|
||||
const contentType = response.headers?.get?.('content-type');
|
||||
if (contentType && !contentType.includes('application/x-ndjson')) {
|
||||
throw new Error('Invalid response format');
|
||||
// Ollama may return application/x-ndjson, application/json, or no content-type at all.
|
||||
// Reject only obvious non-JSON responses (e.g., HTML error pages).
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
throw new Error('Invalid response format: server returned HTML instead of JSON');
|
||||
}
|
||||
|
||||
reader = response.body.getReader();
|
||||
@@ -196,10 +206,12 @@ export class OllamaClient {
|
||||
}
|
||||
|
||||
if (parsed.error) {
|
||||
throw new Error(`Ollama error: ${parsed.error}`);
|
||||
const errorMsg =
|
||||
typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error);
|
||||
throw new Error(`Ollama error: ${errorMsg}`);
|
||||
}
|
||||
|
||||
yield this.normalizeMessage(parsed.message);
|
||||
yield this.normalizeMessage(parsed.message, parsed.prompt_eval_count, parsed.eval_count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,18 +228,23 @@ export class OllamaClient {
|
||||
}
|
||||
|
||||
if (parsed?.error) {
|
||||
throw new Error(`Ollama error: ${parsed.error}`);
|
||||
const errorMsg =
|
||||
typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error);
|
||||
throw new Error(`Ollama error: ${errorMsg}`);
|
||||
}
|
||||
|
||||
if (parsed?.message) {
|
||||
yield this.normalizeMessage(parsed.message);
|
||||
yield this.normalizeMessage(parsed.message, parsed.prompt_eval_count, parsed.eval_count);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
||||
Logger.warn(
|
||||
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount)));
|
||||
yield* this.streamChatWithRetry(messages, tools, retryCount + 1);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -264,19 +281,28 @@ export class OllamaClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(
|
||||
`Model "${this.model}" not found. Run \`ollama pull ${this.model}\` first.`,
|
||||
404
|
||||
);
|
||||
}
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json() as unknown;
|
||||
const data = (await response.json()) as unknown;
|
||||
if (!this.isChatResponse(data)) {
|
||||
return this.normalizeMessage();
|
||||
}
|
||||
return this.normalizeMessage(data.message);
|
||||
return this.normalizeMessage(data.message, data.prompt_eval_count, data.eval_count);
|
||||
} catch (error) {
|
||||
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`, 'ollama-client');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * Math.pow(2, retryCount)));
|
||||
Logger.warn(
|
||||
`Retrying after error (attempt ${retryCount + 1}): ${errorMessage}`,
|
||||
'ollama-client'
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount)));
|
||||
return this.chatWithRetry(messages, tools, retryCount + 1);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -288,12 +314,18 @@ export class OllamaClient {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMessage(message?: Partial<OllamaMessage>): OllamaMessage {
|
||||
private normalizeMessage(
|
||||
message?: Partial<OllamaMessage>,
|
||||
promptEvalCount?: number,
|
||||
evalCount?: number
|
||||
): OllamaMessage {
|
||||
return {
|
||||
role: message?.role ?? 'assistant',
|
||||
content: message?.content ?? '',
|
||||
tool_calls: message?.tool_calls ?? [],
|
||||
tool_call_id: message?.tool_call_id,
|
||||
prompt_eval_count: promptEvalCount,
|
||||
eval_count: evalCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +393,7 @@ export class OllamaClient {
|
||||
error.message.startsWith('Ollama error:') ||
|
||||
error.message.includes('Too many malformed chunks') ||
|
||||
error.message === 'No response body' ||
|
||||
error.message === 'Invalid response format'
|
||||
error.message.startsWith('Invalid response format')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -369,4 +401,57 @@ export class OllamaClient {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async listModels(): Promise<{ name: string; size?: number; modified?: string }[]> {
|
||||
try {
|
||||
const response = await this.fetchFn(`${this.baseURL}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Ollama API error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'models' in data &&
|
||||
Array.isArray((data as { models: unknown }).models)
|
||||
) {
|
||||
const models = (data as { models: unknown[] }).models;
|
||||
return models
|
||||
.filter(
|
||||
(m): m is { name: string; size?: number; modified_at?: string } =>
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
'name' in m &&
|
||||
typeof (m as { name: unknown }).name === 'string'
|
||||
)
|
||||
.map((m) => ({
|
||||
name: m.name,
|
||||
size: m.size,
|
||||
modified: m.modified_at,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
`Failed to list models: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'ollama-client'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
setModel(model: string): void {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
getModel(): string {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-17
@@ -1,13 +1,12 @@
|
||||
// src/semantic-cache.ts
|
||||
|
||||
import { ChromaClient, Collection } from 'chromadb';
|
||||
import { Logger } from './utils';
|
||||
import { CacheConfig } from './types';
|
||||
|
||||
export class SemanticCacheService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private client: any | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private collection: any | null = null;
|
||||
private client: ChromaClient | null = null;
|
||||
private collection: Collection | null = null;
|
||||
private config: CacheConfig;
|
||||
private ollamaURL: string;
|
||||
|
||||
@@ -20,10 +19,9 @@ export class SemanticCacheService {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
// Dynamic import — chromadb is optional and may not be installed.
|
||||
// This prevents the plugin from crashing at load time if chromadb is absent.
|
||||
const { ChromaClient } = await import('chromadb');
|
||||
const chromaURL = this.config.chromaURL || 'http://localhost:8000';
|
||||
const rawURL = this.config.chromaURL?.trim() || 'http://localhost:8000';
|
||||
// Guard against malformed URLs like 'http://:8666'
|
||||
const chromaURL = rawURL.includes('://') ? rawURL : 'http://localhost:8000';
|
||||
this.client = new ChromaClient({ path: chromaURL });
|
||||
this.collection = await this.client.getOrCreateCollection({
|
||||
name: this.config.collectionName,
|
||||
@@ -43,13 +41,15 @@ export class SemanticCacheService {
|
||||
|
||||
try {
|
||||
const results = await this.collection.query({
|
||||
query_embeddings: await this.generateEmbedding(query),
|
||||
n_results: 1,
|
||||
queryEmbeddings: [await this.generateEmbedding(query)],
|
||||
nResults: 1,
|
||||
where: { source: 'ollama' },
|
||||
});
|
||||
|
||||
if (results.ids[0] && results.ids[0].length > 0) {
|
||||
if (results.distances[0] && results.distances[0][0] > this.config.similarityThreshold) {
|
||||
const distance = results.distances?.[0]?.[0];
|
||||
const similarity = typeof distance === 'number' ? 1 - distance : 0;
|
||||
if (similarity >= this.config.similarityThreshold) {
|
||||
return results.documents[0][0];
|
||||
}
|
||||
}
|
||||
@@ -62,14 +62,22 @@ export class SemanticCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for environments without crypto.randomUUID
|
||||
return 'cache_' + Date.now() + '_' + Math.random().toString(36).substring(2, 11);
|
||||
}
|
||||
|
||||
async setCache(query: string, response: string): Promise<void> {
|
||||
if (!this.config.enabled || !this.collection) return;
|
||||
|
||||
try {
|
||||
await this.collection.add({
|
||||
ids: [crypto.randomUUID()],
|
||||
await this.collection.upsert({
|
||||
ids: [SemanticCacheService.generateId()],
|
||||
documents: [response],
|
||||
embeddings: await this.generateEmbedding(query),
|
||||
embeddings: [await this.generateEmbedding(query)],
|
||||
metadatas: [{ source: 'ollama' }],
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -79,10 +87,11 @@ export class SemanticCacheService {
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
if (!this.config.enabled || !this.collection) return;
|
||||
if (!this.config.enabled || !this.client) return;
|
||||
|
||||
try {
|
||||
await this.collection.reset();
|
||||
await this.client.deleteCollection({ name: this.config.collectionName });
|
||||
this.collection = null;
|
||||
Logger.info('Semantic cache cleared', 'semantic-cache');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -106,7 +115,7 @@ export class SemanticCacheService {
|
||||
throw new Error(`Failed to generate embedding: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as { embedding: number[] };
|
||||
return data.embedding;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// src/structured-memory.ts
|
||||
|
||||
import {
|
||||
StructuredMemoryData,
|
||||
StructuredMemoryConfig,
|
||||
ConversationSummary,
|
||||
UserPreference,
|
||||
LearnedFact,
|
||||
OllamaMessage,
|
||||
} from './types';
|
||||
|
||||
export function createDefaultStructuredMemoryData(): StructuredMemoryData {
|
||||
return {
|
||||
conversationSummaries: [],
|
||||
userPreferences: [],
|
||||
learnedFacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the agent's structured memory: conversation summaries,
|
||||
* user preferences, and learned facts. Persists in plugin data JSON.
|
||||
*/
|
||||
export class StructuredMemoryManager {
|
||||
private data: StructuredMemoryData;
|
||||
private config: StructuredMemoryConfig;
|
||||
|
||||
constructor(config: StructuredMemoryConfig, initialData?: StructuredMemoryData) {
|
||||
this.config = config;
|
||||
this.data = initialData ?? createDefaultStructuredMemoryData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the in-memory data (e.g., after loading from disk).
|
||||
*/
|
||||
loadData(data: StructuredMemoryData): void {
|
||||
this.data = {
|
||||
conversationSummaries: data.conversationSummaries ?? [],
|
||||
userPreferences: data.userPreferences ?? [],
|
||||
learnedFacts: data.learnedFacts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a serializable copy of the current memory data.
|
||||
*/
|
||||
getData(): StructuredMemoryData {
|
||||
return {
|
||||
conversationSummaries: [...this.data.conversationSummaries],
|
||||
userPreferences: [...this.data.userPreferences],
|
||||
learnedFacts: [...this.data.learnedFacts],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the config (e.g., when settings change).
|
||||
*/
|
||||
updateConfig(config: StructuredMemoryConfig): void {
|
||||
this.config = config;
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a conversation summary, keeping the newest within maxSummaries.
|
||||
*/
|
||||
addConversationSummary(summary: ConversationSummary): void {
|
||||
if (!this.config.enabled) return;
|
||||
this.data.conversationSummaries.push(summary);
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
getConversationSummaries(): ConversationSummary[] {
|
||||
return [...this.data.conversationSummaries];
|
||||
}
|
||||
|
||||
clearConversationSummaries(): void {
|
||||
this.data.conversationSummaries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a user preference. If the key already exists, update it.
|
||||
*/
|
||||
addUserPreference(preference: UserPreference): void {
|
||||
if (!this.config.enabled) return;
|
||||
const existingIndex = this.data.userPreferences.findIndex((p) => p.key === preference.key);
|
||||
if (existingIndex >= 0) {
|
||||
this.data.userPreferences[existingIndex] = preference;
|
||||
} else {
|
||||
this.data.userPreferences.push(preference);
|
||||
}
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
getUserPreference(key: string): UserPreference | undefined {
|
||||
return this.data.userPreferences.find((p) => p.key === key);
|
||||
}
|
||||
|
||||
getUserPreferences(): UserPreference[] {
|
||||
return [...this.data.userPreferences];
|
||||
}
|
||||
|
||||
removeUserPreference(key: string): void {
|
||||
this.data.userPreferences = this.data.userPreferences.filter((p) => p.key !== key);
|
||||
}
|
||||
|
||||
clearUserPreferences(): void {
|
||||
this.data.userPreferences = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a learned fact, deduplicating by content (case-insensitive).
|
||||
*/
|
||||
addLearnedFact(fact: LearnedFact): void {
|
||||
if (!this.config.enabled) return;
|
||||
const normalizedContent = fact.content.trim().toLowerCase();
|
||||
const existingIndex = this.data.learnedFacts.findIndex(
|
||||
(f) => f.content.trim().toLowerCase() === normalizedContent
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Update confidence and timestamp if duplicate
|
||||
this.data.learnedFacts[existingIndex] = {
|
||||
...fact,
|
||||
timestamp: Date.now(),
|
||||
confidence: Math.max(fact.confidence, this.data.learnedFacts[existingIndex].confidence),
|
||||
};
|
||||
} else {
|
||||
this.data.learnedFacts.push(fact);
|
||||
}
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
getLearnedFacts(): LearnedFact[] {
|
||||
return [...this.data.learnedFacts];
|
||||
}
|
||||
|
||||
getLearnedFactsByCategory(category: LearnedFact['category']): LearnedFact[] {
|
||||
return this.data.learnedFacts.filter((f) => f.category === category);
|
||||
}
|
||||
|
||||
clearLearnedFacts(): void {
|
||||
this.data.learnedFacts = [];
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.data = createDefaultStructuredMemoryData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a context string from stored memory for injection into the system prompt.
|
||||
* Returns an empty string if memory is disabled or empty.
|
||||
*/
|
||||
buildMemoryContext(): string {
|
||||
if (!this.config.enabled) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
const summaries = this.data.conversationSummaries;
|
||||
if (summaries.length > 0) {
|
||||
parts.push('## Past Conversations');
|
||||
for (const s of summaries.slice(-3)) {
|
||||
parts.push(`- ${s.topic}: ${s.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
const preferences = this.data.userPreferences;
|
||||
if (preferences.length > 0) {
|
||||
parts.push('## User Preferences');
|
||||
for (const p of preferences) {
|
||||
parts.push(`- ${p.key}: ${p.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const facts = this.data.learnedFacts;
|
||||
if (facts.length > 0) {
|
||||
parts.push('## Learned Facts');
|
||||
for (const f of facts.filter((fact) => fact.confidence >= 0.5).slice(-10)) {
|
||||
parts.push(`- ${f.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return '';
|
||||
return 'The following is remembered context from past sessions:\n' + parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract likely user preferences from a message using lightweight regex heuristics.
|
||||
*/
|
||||
extractPreferencesFromMessage(message: string): UserPreference[] {
|
||||
if (!this.config.enabled) return [];
|
||||
|
||||
const preferences: UserPreference[] = [];
|
||||
const patterns = [
|
||||
{ regex: /i(?:'d| would)?\s+prefer\s+(?:that\s+)?(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /i\s+(?:like|love|enjoy)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /i\s+(?:dislike|hate|avoid)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{ regex: /please\s+(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
|
||||
{
|
||||
regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i,
|
||||
keyPrefix: 'favorite',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { regex, keyPrefix } of patterns) {
|
||||
const match = regex.exec(message);
|
||||
if (match) {
|
||||
const value = match[match.length - 1].trim();
|
||||
const key =
|
||||
value.length > 30
|
||||
? `${keyPrefix}-${Date.now()}`
|
||||
: `${keyPrefix}-${value.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
preferences.push({
|
||||
key,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
source: 'inferred',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract likely facts from a message using lightweight regex heuristics.
|
||||
*/
|
||||
extractFactsFromMessage(message: string): LearnedFact[] {
|
||||
if (!this.config.enabled) return [];
|
||||
|
||||
const facts: LearnedFact[] = [];
|
||||
|
||||
// Vault structure patterns
|
||||
const folderPattern = /(\/[^\s]+\/(?:[^\s/]+\/)*)/g;
|
||||
const folderMatches = message.matchAll(folderPattern);
|
||||
for (const match of folderMatches) {
|
||||
facts.push({
|
||||
id: crypto.randomUUID?.() ?? `fact-${Date.now()}-${Math.random()}`,
|
||||
timestamp: Date.now(),
|
||||
content: `The vault contains a folder at ${match[1]}.`,
|
||||
category: 'vault_structure',
|
||||
confidence: 0.6,
|
||||
});
|
||||
}
|
||||
|
||||
// Topic patterns ("X is a Y")
|
||||
const topicPattern = /(\w+(?:\s+\w+){0,5})\s+is\s+(?:a|an|the)\s+(.+?)(?:\.|$)/gi;
|
||||
const topicMatches = message.matchAll(topicPattern);
|
||||
for (const match of topicMatches) {
|
||||
const subject = match[1].trim();
|
||||
const predicate = match[2].trim();
|
||||
if (subject.length > 2 && predicate.length > 2) {
|
||||
facts.push({
|
||||
id: crypto.randomUUID?.() ?? `fact-${Date.now()}-${Math.random()}`,
|
||||
timestamp: Date.now(),
|
||||
content: `${subject} is ${predicate}.`,
|
||||
category: 'topic',
|
||||
confidence: 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple topic string from a conversation by looking at the first user message.
|
||||
*/
|
||||
summarizeConversation(messages: OllamaMessage[]): { topic: string; keyPoints: string[] } {
|
||||
const firstUser = messages.find((m) => m.role === 'user');
|
||||
const topic = firstUser
|
||||
? firstUser.content.slice(0, 60).replace(/\n/g, ' ')
|
||||
: 'Untitled conversation';
|
||||
|
||||
const keyPoints: string[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'assistant' && msg.content) {
|
||||
const sentences = msg.content
|
||||
.split(/[.!?]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 10 && s.length < 120);
|
||||
keyPoints.push(...sentences.slice(0, 2));
|
||||
}
|
||||
if (keyPoints.length >= 3) break;
|
||||
}
|
||||
|
||||
return { topic, keyPoints };
|
||||
}
|
||||
|
||||
private enforceLimits(): void {
|
||||
if (this.data.conversationSummaries.length > this.config.maxSummaries) {
|
||||
this.data.conversationSummaries = this.data.conversationSummaries.slice(
|
||||
-this.config.maxSummaries
|
||||
);
|
||||
}
|
||||
if (this.data.userPreferences.length > this.config.maxPreferences) {
|
||||
// Keep most recently updated preferences
|
||||
const sorted = [...this.data.userPreferences].sort((a, b) => b.timestamp - a.timestamp);
|
||||
this.data.userPreferences = sorted.slice(0, this.config.maxPreferences);
|
||||
}
|
||||
if (this.data.learnedFacts.length > this.config.maxFacts) {
|
||||
// Keep highest-confidence facts
|
||||
const sorted = [...this.data.learnedFacts].sort((a, b) => b.confidence - a.confidence);
|
||||
this.data.learnedFacts = sorted.slice(0, this.config.maxFacts);
|
||||
}
|
||||
}
|
||||
}
|
||||
+643
-33
@@ -1,8 +1,11 @@
|
||||
// src/tool-executor.ts
|
||||
|
||||
import { Vault, App, TFile } from 'obsidian';
|
||||
import type { ToolCall, ToolResult } from './types';
|
||||
import { safeParseJson } from './utils';
|
||||
import { Vault, App, TFile, TFolder } from 'obsidian';
|
||||
import type { ToolCall, ToolResult, VaultIndexEntry } from './types';
|
||||
import { safeParseJson, Logger } from './utils';
|
||||
import { TelemetryManager } from './tool-telemetry';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
import { UndoManager } from './undo-manager';
|
||||
|
||||
// Disallow characters that are invalid in file paths
|
||||
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
||||
@@ -12,10 +15,22 @@ const FORBIDDEN_DIRS = ['.obsidian', '.git'];
|
||||
export class ToolExecutor {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
private telemetryManager?: TelemetryManager;
|
||||
private vaultIndexer?: VaultIndexer;
|
||||
private undoManager?: UndoManager;
|
||||
|
||||
constructor(vault: Vault, app: App) {
|
||||
constructor(
|
||||
vault: Vault,
|
||||
app: App,
|
||||
telemetryManager?: TelemetryManager,
|
||||
vaultIndexer?: VaultIndexer,
|
||||
undoManager?: UndoManager
|
||||
) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
this.telemetryManager = telemetryManager;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
this.undoManager = undoManager;
|
||||
}
|
||||
|
||||
private isSafePath(path: string): boolean {
|
||||
@@ -51,16 +66,14 @@ export class ToolExecutor {
|
||||
|
||||
// Reject paths that traverse to parent directories
|
||||
const normalized = path.replace(/^(\.\/)+/, '');
|
||||
if (normalized.split('/').includes('..')) {
|
||||
const segments = normalized.split('/').filter((segment) => segment.length > 0);
|
||||
if (segments.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject forbidden directories
|
||||
for (const dir of FORBIDDEN_DIRS) {
|
||||
if (normalized.startsWith(`${dir}/`) || normalized.startsWith(`${dir}\\`)) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes(`/${dir}/`) || normalized.includes(`\\${dir}\\`)) {
|
||||
for (const segment of segments) {
|
||||
if (FORBIDDEN_DIRS.includes(segment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -68,9 +81,59 @@ export class ToolExecutor {
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
|
||||
private getFile(path: string): TFile {
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private async readFileContent(path: string): Promise<string> {
|
||||
const file = this.getFile(path);
|
||||
return await this.vault.cachedRead(file);
|
||||
}
|
||||
|
||||
private async writeFileContent(path: string, content: string): Promise<void> {
|
||||
const file = this.getFile(path);
|
||||
await this.vault.modify(file, content);
|
||||
}
|
||||
|
||||
private getParentFolderPath(path: string): string {
|
||||
const parts = path.split('/').filter((part) => part.length > 0);
|
||||
parts.pop();
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
private async ensureFolderExists(folderPath: string): Promise<void> {
|
||||
const normalizedFolder = folderPath.replace(/\/$/, '').trim();
|
||||
if (!normalizedFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = normalizedFolder.split('/').filter((part) => part.length > 0);
|
||||
let currentPath = '';
|
||||
for (const part of parts) {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
const existing = this.vault.getAbstractFileByPath(currentPath);
|
||||
if (existing) {
|
||||
if (!(existing instanceof TFolder)) {
|
||||
throw new Error(`Cannot create folder ${currentPath}: a file already exists at that path`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await this.vault.createFolder(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
async handleToolCall(toolCall: ToolCall, undoBatchId?: string): Promise<ToolResult> {
|
||||
const startTime = Date.now();
|
||||
const toolName = toolCall.function?.name ?? 'unknown';
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
let result: ToolResult = { success: false, message: 'No result' };
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
const toolName = toolCall.function?.name;
|
||||
const rawArgs = toolCall.function?.arguments;
|
||||
|
||||
if (!toolName) {
|
||||
@@ -78,7 +141,6 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
// Parse arguments whether they're a string or object
|
||||
let parsedArgs: Record<string, unknown>;
|
||||
if (typeof rawArgs === 'string') {
|
||||
try {
|
||||
parsedArgs = safeParseJson(rawArgs) as Record<string, unknown>;
|
||||
@@ -91,24 +153,74 @@ export class ToolExecutor {
|
||||
throw new Error('Arguments must be an object or JSON string');
|
||||
}
|
||||
|
||||
// Snapshot state before write operations for undo
|
||||
if (undoBatchId) {
|
||||
await this.snapshotForUndo(toolName, parsedArgs, undoBatchId);
|
||||
}
|
||||
|
||||
// Process the tool call based on its type
|
||||
switch (toolName) {
|
||||
case 'create_file':
|
||||
return await this.handleCreateFile(parsedArgs);
|
||||
case 'create_note':
|
||||
result = await this.handleCreateNote(parsedArgs);
|
||||
break;
|
||||
case 'read_vault_file':
|
||||
return await this.handleReadVaultFile(parsedArgs);
|
||||
result = await this.handleReadVaultFile(parsedArgs);
|
||||
break;
|
||||
case 'search_vault_files':
|
||||
return this.handleSearchVaultFiles(parsedArgs);
|
||||
result = await this.handleSearchVaultFiles(parsedArgs);
|
||||
break;
|
||||
case 'append_to_note':
|
||||
result = await this.handleAppendToNote(parsedArgs);
|
||||
break;
|
||||
case 'replace_note_section':
|
||||
result = await this.handleReplaceNoteSection(parsedArgs);
|
||||
break;
|
||||
case 'update_frontmatter':
|
||||
result = await this.handleUpdateFrontmatter(parsedArgs);
|
||||
break;
|
||||
case 'rename_note':
|
||||
result = await this.handleRenameNote(parsedArgs);
|
||||
break;
|
||||
case 'move_note':
|
||||
result = await this.handleMoveNote(parsedArgs);
|
||||
break;
|
||||
case 'delete_note':
|
||||
result = await this.handleDeleteNote(parsedArgs);
|
||||
break;
|
||||
case 'insert_link':
|
||||
result = await this.handleInsertLink(parsedArgs);
|
||||
break;
|
||||
case 'list_vault_tags':
|
||||
result = await this.handleListVaultTags(parsedArgs);
|
||||
break;
|
||||
case 'get_vault_stats':
|
||||
result = await this.handleGetVaultStats(parsedArgs);
|
||||
break;
|
||||
default:
|
||||
return { success: false, message: `Unknown tool: ${toolName}` };
|
||||
result = { success: false, message: `Unknown tool: ${toolName}` };
|
||||
}
|
||||
|
||||
success = result.success;
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
success = false;
|
||||
result = { success: false, message: errorMessage };
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.telemetryManager?.recordToolCall({
|
||||
toolName,
|
||||
args: parsedArgs,
|
||||
success,
|
||||
resultSummary: result?.message ?? 'No result',
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreateFile(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
private async handleCreateNote(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const path = args.path;
|
||||
const content = args.content;
|
||||
|
||||
@@ -124,13 +236,9 @@ export class ToolExecutor {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.vault.create(path, content);
|
||||
return { success: true, message: 'File created successfully' };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
await this.ensureFolderExists(this.getParentFolderPath(path));
|
||||
await this.vault.create(path, content);
|
||||
return { success: true, message: 'Note created successfully' };
|
||||
}
|
||||
|
||||
async executeTool(name: string, args: string | Record<string, unknown>): Promise<ToolResult> {
|
||||
@@ -139,7 +247,7 @@ export class ToolExecutor {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: args as string,
|
||||
arguments: typeof args === 'string' ? args : JSON.stringify(args),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,12 +263,7 @@ export class ToolExecutor {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (!(file instanceof TFile)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const content = await this.readFileContent(path);
|
||||
return {
|
||||
success: true,
|
||||
message: 'File read successfully',
|
||||
@@ -168,7 +271,7 @@ export class ToolExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
private handleSearchVaultFiles(args: Record<string, unknown>): ToolResult {
|
||||
private async handleSearchVaultFiles(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const query = args.query;
|
||||
const limitArg = args.limit;
|
||||
|
||||
@@ -177,6 +280,25 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
const limit = typeof limitArg === 'number' && Number.isFinite(limitArg) ? limitArg : 10;
|
||||
|
||||
// Use VaultIndexer for rich content/tag/search if available
|
||||
if (this.vaultIndexer) {
|
||||
const results = await this.vaultIndexer.searchVault(query, limit);
|
||||
const files = results.map((entry: VaultIndexEntry) => ({
|
||||
path: entry.path,
|
||||
basename: entry.path.split('/').pop() ?? entry.path,
|
||||
title: entry.title,
|
||||
score: entry.score,
|
||||
tags: entry.tags,
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${files.length} matching files`,
|
||||
data: files,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to simple path-based search
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const files = this.vault
|
||||
.getMarkdownFiles()
|
||||
@@ -190,4 +312,492 @@ export class ToolExecutor {
|
||||
data: files,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleAppendToNote(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const path = args.path;
|
||||
const content = args.content;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error('Path must be a string');
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Content must be a string');
|
||||
}
|
||||
if (!this.isSafePath(path)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
const currentContent = await this.readFileContent(path);
|
||||
const separator = currentContent.endsWith('\n') ? '' : '\n';
|
||||
const newContent = currentContent + separator + content;
|
||||
await this.writeFileContent(path, newContent);
|
||||
|
||||
return { success: true, message: 'Content appended successfully' };
|
||||
}
|
||||
|
||||
private async handleReplaceNoteSection(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const path = args.path;
|
||||
const heading = args.heading;
|
||||
const content = args.content;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error('Path must be a string');
|
||||
}
|
||||
if (typeof heading !== 'string') {
|
||||
throw new Error('Heading must be a string');
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Content must be a string');
|
||||
}
|
||||
if (!this.isSafePath(path)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
const fileContent = await this.readFileContent(path);
|
||||
const file = this.getFile(path);
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
|
||||
if (cache?.headings) {
|
||||
const targetHeading = cache.headings.find((h) => h.heading === heading);
|
||||
if (targetHeading) {
|
||||
const startOffset = targetHeading.position.start.offset;
|
||||
const headingLevel = targetHeading.level;
|
||||
const nextHeading = cache.headings.find(
|
||||
(h) => h.position.start.offset > startOffset && h.level <= headingLevel
|
||||
);
|
||||
const sectionEnd = nextHeading ? nextHeading.position.start.offset : fileContent.length;
|
||||
|
||||
const newFileContent =
|
||||
fileContent.slice(0, startOffset) +
|
||||
'#'.repeat(headingLevel) +
|
||||
' ' +
|
||||
heading +
|
||||
'\n' +
|
||||
content +
|
||||
'\n' +
|
||||
fileContent.slice(sectionEnd);
|
||||
|
||||
await this.writeFileContent(path, newFileContent);
|
||||
return { success: true, message: `Section "${heading}" replaced successfully` };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to regex when metadataCache is unavailable
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const headingRegex = new RegExp(`^(#{1,6})\\s+${escapedHeading}\\s*$`, 'm');
|
||||
const match = fileContent.match(headingRegex);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Heading "${heading}" not found in ${path}`);
|
||||
}
|
||||
|
||||
const headingLevel = match[1].length;
|
||||
const headingIndex = match.index!;
|
||||
const afterHeading = headingIndex + match[0].length;
|
||||
|
||||
// Find next heading at same or higher level (fewer #)
|
||||
const nextHeadingRegex = new RegExp(`^(#{1,${headingLevel}})\\s`, 'm');
|
||||
const nextMatch = nextHeadingRegex.exec(fileContent.slice(afterHeading));
|
||||
|
||||
const sectionStart = headingIndex;
|
||||
const sectionEnd = nextMatch ? afterHeading + nextMatch.index : fileContent.length;
|
||||
|
||||
const newFileContent =
|
||||
fileContent.slice(0, sectionStart) +
|
||||
match[0] +
|
||||
'\n' +
|
||||
content +
|
||||
'\n' +
|
||||
fileContent.slice(sectionEnd);
|
||||
await this.writeFileContent(path, newFileContent);
|
||||
|
||||
return { success: true, message: `Section "${heading}" replaced successfully` };
|
||||
}
|
||||
|
||||
private parseFrontmatter(
|
||||
file: TFile,
|
||||
content: string
|
||||
): {
|
||||
exists: boolean;
|
||||
fields: Record<string, unknown>;
|
||||
} {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
if (cache?.frontmatter) {
|
||||
return { exists: true, fields: { ...cache.frontmatter } };
|
||||
}
|
||||
|
||||
// Fallback to regex parsing when metadataCache is unavailable
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
if (!match) {
|
||||
return { exists: false, fields: {} };
|
||||
}
|
||||
|
||||
const raw = match[1];
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > 0) {
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (key) {
|
||||
fields[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { exists: true, fields };
|
||||
}
|
||||
|
||||
private serializeFrontmatter(fields: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}: [${value.join(', ')}]`);
|
||||
} else if (typeof value === 'string') {
|
||||
lines.push(`${key}: ${value}`);
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
lines.push(`${key}: ${value}`);
|
||||
} else {
|
||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
return `---\n${lines.join('\n')}\n---\n`;
|
||||
}
|
||||
|
||||
private async handleUpdateFrontmatter(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const path = args.path;
|
||||
const fields = args.fields;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error('Path must be a string');
|
||||
}
|
||||
if (!this.isSafePath(path)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
||||
throw new Error('Fields must be an object');
|
||||
}
|
||||
|
||||
const content = await this.readFileContent(path);
|
||||
const file = this.getFile(path);
|
||||
const parsed = this.parseFrontmatter(file, content);
|
||||
const newFields = { ...parsed.fields };
|
||||
|
||||
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
||||
if (value === null || value === undefined) {
|
||||
delete newFields[key];
|
||||
} else if (typeof value === 'string') {
|
||||
newFields[key] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
newFields[key] = value;
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
newFields[key] = value;
|
||||
} else {
|
||||
newFields[key] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
const newFrontmatter = this.serializeFrontmatter(newFields);
|
||||
const body = parsed.exists ? content.replace(/^---\n[\s\S]*?\n---\n/, '') : content;
|
||||
const newContent = newFrontmatter + body;
|
||||
|
||||
await this.writeFileContent(path, newContent);
|
||||
return { success: true, message: 'Frontmatter updated successfully' };
|
||||
}
|
||||
|
||||
private async handleRenameNote(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const oldPath = args.oldPath;
|
||||
const newPath = args.newPath;
|
||||
|
||||
if (typeof oldPath !== 'string') {
|
||||
throw new Error('oldPath must be a string');
|
||||
}
|
||||
if (typeof newPath !== 'string') {
|
||||
throw new Error('newPath must be a string');
|
||||
}
|
||||
if (!this.isSafePath(oldPath) || !this.isSafePath(newPath)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
const file = this.getFile(oldPath);
|
||||
await this.ensureFolderExists(this.getParentFolderPath(newPath));
|
||||
await this.vault.rename(file, newPath);
|
||||
return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` };
|
||||
}
|
||||
|
||||
private async handleMoveNote(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const path = args.path;
|
||||
const folder = args.folder;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error('Path must be a string');
|
||||
}
|
||||
if (typeof folder !== 'string') {
|
||||
throw new Error('Folder must be a string');
|
||||
}
|
||||
if (!this.isSafePath(path)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
// Folder validation is more lenient (can be empty for root)
|
||||
const normalizedFolder = folder.replace(/\/$/, '').trim();
|
||||
if (normalizedFolder && !this.isSafePath(normalizedFolder)) {
|
||||
throw new Error('Invalid folder path detected');
|
||||
}
|
||||
|
||||
const file = this.getFile(path);
|
||||
const fileName = file.name;
|
||||
const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName;
|
||||
|
||||
await this.ensureFolderExists(normalizedFolder);
|
||||
await this.vault.rename(file, newPath);
|
||||
return { success: true, message: `Note moved to ${newPath}` };
|
||||
}
|
||||
|
||||
private async handleDeleteNote(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const path = args.path;
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error('Path must be a string');
|
||||
}
|
||||
if (!this.isSafePath(path)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
const file = this.getFile(path);
|
||||
await this.vault.trash(file, true);
|
||||
return { success: true, message: `Note ${path} moved to trash` };
|
||||
}
|
||||
|
||||
private async handleListVaultTags(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const sortBy = args.sortBy === 'count' ? 'count' : 'name';
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const tagMap = new Map<string, { count: number; notes: string[] }>();
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const rawTags: unknown = cache?.frontmatter?.tags;
|
||||
const tagList: string[] = [];
|
||||
if (Array.isArray(rawTags)) {
|
||||
tagList.push(...rawTags.map(String));
|
||||
} else if (typeof rawTags === 'string') {
|
||||
tagList.push(
|
||||
...rawTags
|
||||
.split(/[,\n]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
);
|
||||
}
|
||||
for (const tag of tagList) {
|
||||
const existing = tagMap.get(tag);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
if (existing.notes.length < 5) existing.notes.push(file.path);
|
||||
} else {
|
||||
tagMap.set(tag, { count: 1, notes: [file.path] });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Array.from(tagMap.entries()).map(([tag, data]) => ({
|
||||
tag,
|
||||
count: data.count,
|
||||
sampleNotes: data.notes.slice(0, 3),
|
||||
}));
|
||||
|
||||
if (sortBy === 'count') {
|
||||
entries.sort((a, b) => b.count - a.count);
|
||||
} else {
|
||||
entries.sort((a, b) => a.tag.localeCompare(b.tag));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${entries.length} unique tags across ${files.length} notes`,
|
||||
data: entries,
|
||||
};
|
||||
}
|
||||
|
||||
private handleGetVaultStats(args: Record<string, unknown>): ToolResult {
|
||||
const files = this.vault.getMarkdownFiles();
|
||||
const folderSet = new Set<string>();
|
||||
let totalSize = 0;
|
||||
let taggedCount = 0;
|
||||
let untaggedCount = 0;
|
||||
const tagMap = new Map<string, number>();
|
||||
const recentFiles: { path: string; mtime: number }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const folder = file.path.split('/').slice(0, -1).join('/') || '(root)';
|
||||
folderSet.add(folder);
|
||||
|
||||
if (file.stat?.size) totalSize += file.stat.size;
|
||||
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const rawTags: unknown = cache?.frontmatter?.tags;
|
||||
let hasTags = false;
|
||||
if (Array.isArray(rawTags) && rawTags.length > 0) {
|
||||
hasTags = true;
|
||||
for (const tag of rawTags.map(String)) {
|
||||
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
|
||||
}
|
||||
} else if (
|
||||
typeof rawTags === 'string' &&
|
||||
rawTags.trim().length > 0 &&
|
||||
rawTags.trim() !== '[]'
|
||||
) {
|
||||
hasTags = true;
|
||||
for (const tag of rawTags
|
||||
.split(/[,\n]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)) {
|
||||
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTags) {
|
||||
taggedCount++;
|
||||
} else {
|
||||
untaggedCount++;
|
||||
}
|
||||
|
||||
if (file.stat?.mtime) {
|
||||
recentFiles.push({ path: file.path, mtime: file.stat.mtime });
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
recentFiles.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Vault has ${files.length} notes in ${folderSet.size} folders`,
|
||||
data: {
|
||||
totalNotes: files.length,
|
||||
totalFolders: folderSet.size,
|
||||
folders: Array.from(folderSet).sort(),
|
||||
taggedNotes: taggedCount,
|
||||
untaggedNotes: untaggedCount,
|
||||
topTags: Array.from(tagMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([tag, count]) => ({ tag, count })),
|
||||
avgNoteSize: files.length > 0 ? Math.round(totalSize / files.length) : 0,
|
||||
recentFiles: recentFiles.slice(0, 10).map((f) => f.path),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async snapshotForUndo(
|
||||
toolName: string,
|
||||
parsedArgs: Record<string, unknown>,
|
||||
batchId: string
|
||||
): Promise<void> {
|
||||
if (!this.undoManager) return;
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'create_file':
|
||||
case 'create_note':
|
||||
this.undoManager.recordOperation(batchId, {
|
||||
type: 'create',
|
||||
path: parsedArgs.path as string,
|
||||
});
|
||||
break;
|
||||
case 'append_to_note':
|
||||
case 'replace_note_section':
|
||||
case 'update_frontmatter': {
|
||||
const path = parsedArgs.path as string;
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const originalContent = await this.vault.cachedRead(file);
|
||||
this.undoManager.recordOperation(batchId, { type: 'modify', path, originalContent });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'insert_link': {
|
||||
const path = parsedArgs.sourcePath as string;
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const originalContent = await this.vault.cachedRead(file);
|
||||
this.undoManager.recordOperation(batchId, { type: 'modify', path, originalContent });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rename_note':
|
||||
this.undoManager.recordOperation(batchId, {
|
||||
type: 'rename',
|
||||
originalPath: parsedArgs.oldPath as string,
|
||||
newPath: parsedArgs.newPath as string,
|
||||
});
|
||||
break;
|
||||
case 'move_note': {
|
||||
const path = parsedArgs.path as string;
|
||||
const folder = ((parsedArgs.folder as string) ?? '').replace(/\/$/, '').trim();
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const newPath = folder ? `${folder}/${file.name}` : file.name;
|
||||
this.undoManager.recordOperation(batchId, {
|
||||
type: 'rename',
|
||||
originalPath: path,
|
||||
newPath,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete_note': {
|
||||
const path = parsedArgs.path as string;
|
||||
const file = this.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
const originalContent = await this.vault.cachedRead(file);
|
||||
this.undoManager.recordOperation(batchId, { type: 'trash', path, originalContent });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
`Failed to snapshot for undo (${toolName}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
'tool-executor'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInsertLink(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const sourcePath = args.sourcePath;
|
||||
const targetPath = args.targetPath;
|
||||
const anchorText = args.anchorText;
|
||||
|
||||
if (typeof sourcePath !== 'string') {
|
||||
throw new Error('sourcePath must be a string');
|
||||
}
|
||||
if (typeof targetPath !== 'string') {
|
||||
throw new Error('targetPath must be a string');
|
||||
}
|
||||
if (!this.isSafePath(sourcePath) || !this.isSafePath(targetPath)) {
|
||||
throw new Error('Invalid file path detected');
|
||||
}
|
||||
|
||||
const currentContent = await this.readFileContent(sourcePath);
|
||||
const linkText =
|
||||
typeof anchorText === 'string' && anchorText.trim()
|
||||
? `[[${targetPath}|${anchorText}]]`
|
||||
: `[[${targetPath}]]`;
|
||||
|
||||
const separator = currentContent.endsWith('\n') ? '' : '\n';
|
||||
const newContent = currentContent + separator + linkText + '\n';
|
||||
await this.writeFileContent(sourcePath, newContent);
|
||||
|
||||
return { success: true, message: `Link to ${targetPath} inserted successfully` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
ToolTelemetryEntry,
|
||||
LlmTelemetryEntry,
|
||||
SearchTelemetryEntry,
|
||||
TelemetryEntry,
|
||||
ToolTelemetryData,
|
||||
ToolTelemetryConfig,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
ToolTelemetryEntry,
|
||||
LlmTelemetryEntry,
|
||||
SearchTelemetryEntry,
|
||||
TelemetryEntry,
|
||||
ToolTelemetryData,
|
||||
ToolTelemetryConfig,
|
||||
};
|
||||
|
||||
export function createDefaultToolTelemetryData(): ToolTelemetryData {
|
||||
return {
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID?.() ?? `id-${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
|
||||
export class TelemetryManager {
|
||||
private config: ToolTelemetryConfig;
|
||||
private data: ToolTelemetryData;
|
||||
|
||||
constructor(config: ToolTelemetryConfig, initialData?: ToolTelemetryData) {
|
||||
this.config = config;
|
||||
this.data = initialData
|
||||
? { entries: [...initialData.entries] }
|
||||
: createDefaultToolTelemetryData();
|
||||
}
|
||||
|
||||
loadData(data: ToolTelemetryData): void {
|
||||
this.data = {
|
||||
entries: [...data.entries],
|
||||
};
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
getData(): ToolTelemetryData {
|
||||
return {
|
||||
entries: [...this.data.entries],
|
||||
};
|
||||
}
|
||||
|
||||
updateConfig(config: ToolTelemetryConfig): void {
|
||||
this.config = config;
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
recordToolCall(entry: Omit<ToolTelemetryEntry, 'id' | 'timestamp' | 'type'>): void {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
const fullEntry: ToolTelemetryEntry = {
|
||||
...entry,
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
type: 'tool_call',
|
||||
};
|
||||
this.data.entries.push(fullEntry);
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
recordLlmCall(entry: Omit<LlmTelemetryEntry, 'id' | 'timestamp' | 'type'>): void {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
const fullEntry: LlmTelemetryEntry = {
|
||||
...entry,
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
type: 'llm_call',
|
||||
};
|
||||
this.data.entries.push(fullEntry);
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
recordSearch(entry: Omit<SearchTelemetryEntry, 'id' | 'timestamp' | 'type'>): void {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
const fullEntry: SearchTelemetryEntry = {
|
||||
...entry,
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
type: 'vault_search',
|
||||
};
|
||||
this.data.entries.push(fullEntry);
|
||||
this.enforceLimits();
|
||||
}
|
||||
|
||||
getRecentEntries(limit?: number): TelemetryEntry[] {
|
||||
const sorted = [...this.data.entries].sort((a, b) => b.timestamp - a.timestamp);
|
||||
if (limit !== undefined) {
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
getEntriesByType(type: TelemetryEntry['type']): TelemetryEntry[] {
|
||||
return [...this.data.entries.filter((entry) => entry.type === type)];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.entries = [];
|
||||
}
|
||||
|
||||
private enforceLimits(): void {
|
||||
if (this.data.entries.length > this.config.maxEntries) {
|
||||
this.data.entries = this.data.entries.slice(-this.config.maxEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
@@ -90,6 +90,8 @@ export interface OllamaMessage {
|
||||
content: string;
|
||||
tool_calls?: OllamaToolCall[];
|
||||
tool_call_id?: string;
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
}
|
||||
|
||||
export interface OllamaToolCall {
|
||||
@@ -112,6 +114,7 @@ export interface OllamaTool {
|
||||
[key: string]: {
|
||||
type: string;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
};
|
||||
};
|
||||
required?: string[];
|
||||
@@ -128,11 +131,44 @@ export interface ToolResult {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ProposedAction {
|
||||
id: string;
|
||||
toolCall: ToolCall;
|
||||
operation:
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'search'
|
||||
| 'append'
|
||||
| 'replace_section'
|
||||
| 'update_frontmatter'
|
||||
| 'rename'
|
||||
| 'move'
|
||||
| 'delete'
|
||||
| 'insert_link';
|
||||
path: string;
|
||||
description: string;
|
||||
preview?: {
|
||||
before?: string;
|
||||
after?: string;
|
||||
};
|
||||
status: 'pending' | 'applied' | 'rejected';
|
||||
}
|
||||
|
||||
export interface VaultIndexEntry {
|
||||
path: string;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
tags?: string;
|
||||
mtime?: number;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
folder?: string;
|
||||
tag?: string;
|
||||
includeExactPhrase?: boolean;
|
||||
recencyBoost?: boolean;
|
||||
recencyHalfLifeDays?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -141,6 +177,7 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
isThinking?: boolean;
|
||||
tool_calls?: OllamaToolCall[];
|
||||
// Refinement tracking
|
||||
originalQuery?: string;
|
||||
@@ -163,6 +200,99 @@ export interface DependencyGraph {
|
||||
}[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Agent Modes
|
||||
// ============================================================
|
||||
|
||||
export type AgentMode = 'ask' | 'edit' | 'organize' | 'research' | 'workflow';
|
||||
|
||||
// ============================================================
|
||||
// Structured Memory
|
||||
// ============================================================
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
topic: string;
|
||||
summary: string;
|
||||
keyPoints: string[];
|
||||
}
|
||||
|
||||
export interface UserPreference {
|
||||
key: string;
|
||||
value: string;
|
||||
timestamp: number;
|
||||
source: 'explicit' | 'inferred';
|
||||
}
|
||||
|
||||
export interface LearnedFact {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
category: 'vault_structure' | 'user_workflow' | 'topic' | 'general';
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface StructuredMemoryData {
|
||||
conversationSummaries: ConversationSummary[];
|
||||
userPreferences: UserPreference[];
|
||||
learnedFacts: LearnedFact[];
|
||||
}
|
||||
|
||||
export interface StructuredMemoryConfig {
|
||||
enabled: boolean;
|
||||
maxSummaries: number;
|
||||
maxPreferences: number;
|
||||
maxFacts: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tool Telemetry
|
||||
// ============================================================
|
||||
|
||||
export interface ToolTelemetryEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: 'tool_call';
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
success: boolean;
|
||||
resultSummary: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface LlmTelemetryEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: 'llm_call';
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SearchTelemetryEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: 'vault_search';
|
||||
query: string;
|
||||
resultsCount: number;
|
||||
resultPaths: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type TelemetryEntry = ToolTelemetryEntry | LlmTelemetryEntry | SearchTelemetryEntry;
|
||||
|
||||
export interface ToolTelemetryData {
|
||||
entries: TelemetryEntry[];
|
||||
}
|
||||
|
||||
export interface ToolTelemetryConfig {
|
||||
enabled: boolean;
|
||||
maxEntries: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Plugin Configuration
|
||||
// ============================================================
|
||||
@@ -175,13 +305,59 @@ export interface CacheConfig {
|
||||
chromaURL?: string;
|
||||
}
|
||||
|
||||
export interface VaultIndexConfig {
|
||||
enabled: boolean;
|
||||
collectionName: string;
|
||||
embeddingModel: string;
|
||||
chromaURL?: string;
|
||||
similarityThreshold: number;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messages: ChatMessage[];
|
||||
agentMode: AgentMode;
|
||||
}
|
||||
|
||||
export interface ChatHistoryData {
|
||||
sessions: ChatSession[];
|
||||
activeSessionId?: string;
|
||||
}
|
||||
|
||||
export interface PluginSettings {
|
||||
ollamaUrl: string;
|
||||
chatModel: string;
|
||||
agentModel: string;
|
||||
model: string;
|
||||
vaultSearchLimit: number;
|
||||
maxMessageHistory: number;
|
||||
maxContextLength: number;
|
||||
lastIndexTime: number;
|
||||
agentMode: AgentMode;
|
||||
cacheConfig: CacheConfig;
|
||||
vaultIndexConfig: VaultIndexConfig;
|
||||
autoTagConfig: {
|
||||
enabled: boolean;
|
||||
maxTagsPerNote: number;
|
||||
minNoteLength: number;
|
||||
maxNoteLength: number;
|
||||
tagPromptTemplate: string;
|
||||
dryRun: boolean;
|
||||
targetFolder: string;
|
||||
normalizeTags: boolean;
|
||||
};
|
||||
autoLinkConfig: {
|
||||
enabled: boolean;
|
||||
maxLinksPerNote: number;
|
||||
similarityThreshold: number;
|
||||
targetFolder: string;
|
||||
dryRun: boolean;
|
||||
};
|
||||
structuredMemoryConfig: StructuredMemoryConfig;
|
||||
toolTelemetryConfig: ToolTelemetryConfig;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Vault, TFile } from 'obsidian';
|
||||
import { Logger } from './utils';
|
||||
|
||||
export type UndoOperation =
|
||||
| { type: 'create'; path: string }
|
||||
| { type: 'modify'; path: string; originalContent: string }
|
||||
| { type: 'rename'; originalPath: string; newPath: string }
|
||||
| { type: 'trash'; path: string; originalContent: string };
|
||||
|
||||
export interface UndoBatch {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
operations: UndoOperation[];
|
||||
}
|
||||
|
||||
export class UndoManager {
|
||||
private batches: UndoBatch[] = [];
|
||||
private readonly maxBatches = 10;
|
||||
|
||||
startBatch(): string {
|
||||
const id = crypto.randomUUID?.() ?? `undo-${Date.now()}-${Math.random()}`;
|
||||
this.batches.push({ id, timestamp: Date.now(), operations: [] });
|
||||
if (this.batches.length > this.maxBatches) {
|
||||
this.batches = this.batches.slice(-this.maxBatches);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
recordOperation(batchId: string, op: UndoOperation): void {
|
||||
const batch = this.batches.find((b) => b.id === batchId);
|
||||
batch?.operations.push(op);
|
||||
}
|
||||
|
||||
getBatch(batchId: string): UndoBatch | undefined {
|
||||
return this.batches.find((b) => b.id === batchId);
|
||||
}
|
||||
|
||||
hasBatch(batchId: string): boolean {
|
||||
const batch = this.getBatch(batchId);
|
||||
return !!(batch && batch.operations.length > 0);
|
||||
}
|
||||
|
||||
async undo(batchId: string, vault: Vault): Promise<{ restored: number; failed: number }> {
|
||||
const batch = this.getBatch(batchId);
|
||||
if (!batch) return { restored: 0, failed: 0 };
|
||||
|
||||
let restored = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const op of [...batch.operations].reverse()) {
|
||||
try {
|
||||
switch (op.type) {
|
||||
case 'create': {
|
||||
const file = vault.getAbstractFileByPath(op.path);
|
||||
if (file instanceof TFile) {
|
||||
await vault.trash(file, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'modify': {
|
||||
const file = vault.getAbstractFileByPath(op.path);
|
||||
if (file instanceof TFile) {
|
||||
await vault.modify(file, op.originalContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
const file = vault.getAbstractFileByPath(op.newPath);
|
||||
if (file instanceof TFile) {
|
||||
await vault.rename(file, op.originalPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'trash': {
|
||||
const existing = vault.getAbstractFileByPath(op.path);
|
||||
if (existing instanceof TFile) {
|
||||
await vault.modify(existing, op.originalContent);
|
||||
} else {
|
||||
await vault.create(op.path, op.originalContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
restored++;
|
||||
} catch (error) {
|
||||
const path = 'path' in op ? op.path : 'originalPath' in op ? op.originalPath : '?';
|
||||
Logger.warn(
|
||||
`Undo failed for ${op.type} on ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'undo-manager'
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.batches = this.batches.filter((b) => b.id !== batchId);
|
||||
return { restored, failed };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.batches = [];
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,19 @@ const SEVERITY_ORDER: Record<string, number> = {
|
||||
error: LogLevel.ERROR,
|
||||
};
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: LogLevel;
|
||||
levelLabel: string;
|
||||
category: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private static minLevel: LogLevel = LogLevel.DEBUG;
|
||||
private static listeners: Array<(entry: LogEntry) => void> = [];
|
||||
private static history: LogEntry[] = [];
|
||||
private static maxHistory: number = 500;
|
||||
|
||||
static setLevel(level: string | LogLevel): void {
|
||||
if (typeof level === 'string') {
|
||||
@@ -24,28 +35,75 @@ export class Logger {
|
||||
}
|
||||
}
|
||||
|
||||
static addListener(callback: (entry: LogEntry) => void): () => void {
|
||||
Logger.listeners.push(callback);
|
||||
return () => {
|
||||
const idx = Logger.listeners.indexOf(callback);
|
||||
if (idx !== -1) {
|
||||
Logger.listeners.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getHistory(): LogEntry[] {
|
||||
return [...Logger.history];
|
||||
}
|
||||
|
||||
private static record(
|
||||
level: LogLevel,
|
||||
levelLabel: string,
|
||||
message: string,
|
||||
category: string
|
||||
): void {
|
||||
if (level < Logger.minLevel) {
|
||||
return;
|
||||
}
|
||||
const entry: LogEntry = {
|
||||
timestamp: Date.now(),
|
||||
level,
|
||||
levelLabel,
|
||||
category,
|
||||
message,
|
||||
};
|
||||
Logger.history.push(entry);
|
||||
if (Logger.history.length > Logger.maxHistory) {
|
||||
Logger.history = Logger.history.slice(-Logger.maxHistory);
|
||||
}
|
||||
for (const listener of Logger.listeners) {
|
||||
try {
|
||||
listener(entry);
|
||||
} catch {
|
||||
// ignore listener errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static debug(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.DEBUG >= Logger.minLevel) {
|
||||
console.debug(`[${category}] DEBUG: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.DEBUG, 'DEBUG', message, category);
|
||||
}
|
||||
|
||||
static info(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.INFO >= Logger.minLevel) {
|
||||
console.info(`[${category}] INFO: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.INFO, 'INFO', message, category);
|
||||
}
|
||||
|
||||
static warn(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.WARN >= Logger.minLevel) {
|
||||
console.warn(`[${category}] WARN: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.WARN, 'WARN', message, category);
|
||||
}
|
||||
|
||||
static error(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.ERROR >= Logger.minLevel) {
|
||||
console.error(`[${category}] ERROR: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.ERROR, 'ERROR', message, category);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+263
-47
@@ -1,8 +1,10 @@
|
||||
// src/vault-indexer.ts
|
||||
|
||||
import { Vault, TFile } from 'obsidian';
|
||||
import { Vault, TFile, App } from 'obsidian';
|
||||
import { Logger } from './utils';
|
||||
import { Cache } from './cache';
|
||||
import { VaultVectorStore } from './vault-vector-store';
|
||||
import { VaultIndexEntry, SearchOptions } from './types';
|
||||
|
||||
interface ParsedFrontmatter {
|
||||
title?: string;
|
||||
@@ -43,30 +45,98 @@ export class InMemoryCache implements Cache {
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a', 'an', 'the', 'is', 'it', 'in', 'on', 'at', 'to', 'for', 'of',
|
||||
'and', 'or', 'but', 'with', 'by', 'from', 'up', 'about', 'into',
|
||||
'this', 'that', 'these', 'those', 'be', 'been', 'being', 'have',
|
||||
'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
|
||||
'may', 'might', 'can', 'are', 'was', 'were', 'as', 'so', 'if', 'not',
|
||||
'no', 'my', 'your', 'our', 'its', 'we', 'you', 'he', 'she', 'they',
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'is',
|
||||
'it',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'up',
|
||||
'about',
|
||||
'into',
|
||||
'this',
|
||||
'that',
|
||||
'these',
|
||||
'those',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'will',
|
||||
'would',
|
||||
'could',
|
||||
'should',
|
||||
'may',
|
||||
'might',
|
||||
'can',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'as',
|
||||
'so',
|
||||
'if',
|
||||
'not',
|
||||
'no',
|
||||
'my',
|
||||
'your',
|
||||
'our',
|
||||
'its',
|
||||
'we',
|
||||
'you',
|
||||
'he',
|
||||
'she',
|
||||
'they',
|
||||
]);
|
||||
|
||||
const CONTENT_PREVIEW_LENGTH = 500;
|
||||
const DAYS_TO_MS = 86400000;
|
||||
const DEFAULT_RECENCY_HALF_LIFE = 30; // 30 days
|
||||
|
||||
export class VaultIndexer {
|
||||
private vault: Vault;
|
||||
private app?: App;
|
||||
private cache?: Cache;
|
||||
private vectorStore?: VaultVectorStore;
|
||||
private readonly SCORING_WEIGHTS = {
|
||||
TITLE: 5,
|
||||
FRONTMATTER_TITLE: 4,
|
||||
FRONTMATTER_TAGS: 3,
|
||||
HEADINGS: 2,
|
||||
CONTENT: 1,
|
||||
FILENAME: 3,
|
||||
EXACT_PHRASE: 8,
|
||||
LINKED: 2,
|
||||
RECENT: 0.5, // multiplier, not additive
|
||||
};
|
||||
|
||||
constructor(vault: Vault, cache?: Cache) {
|
||||
constructor(vault: Vault, cache?: Cache, vectorStore?: VaultVectorStore) {
|
||||
this.vault = vault;
|
||||
this.cache = cache;
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
setVectorStore(vectorStore: VaultVectorStore | undefined): void {
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
setApp(app: App | undefined): void {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
tokenize(text: string): string[] {
|
||||
@@ -78,7 +148,7 @@ export class VaultIndexer {
|
||||
}
|
||||
|
||||
tokenizeContent(content: string, file: TFile): TokenizedContent {
|
||||
const parsed = this.parseMarkdown(content);
|
||||
const parsed = this.parseMarkdown(content, file);
|
||||
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, '');
|
||||
const paragraphs = bodyWithoutFrontmatter
|
||||
.split(/\n\n+/)
|
||||
@@ -97,15 +167,28 @@ export class VaultIndexer {
|
||||
|
||||
calculateWeightedScore(
|
||||
tokenized: TokenizedContent,
|
||||
queryTokens: string[]
|
||||
): { score: number } {
|
||||
queryTokens: string[],
|
||||
exactPhrases: string[]
|
||||
): number {
|
||||
let score = 0;
|
||||
const fullText = [
|
||||
tokenized.title,
|
||||
tokenized.headings.join(' '),
|
||||
tokenized.frontmatter.title ?? '',
|
||||
tokenized.frontmatter.tags ?? '',
|
||||
tokenized.content,
|
||||
tokenized.firstParagraph,
|
||||
tokenized.basename,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
for (const token of queryTokens) {
|
||||
if (tokenized.frontmatter?.title && this.exactMatch(tokenized.frontmatter.title, token)) {
|
||||
score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
}
|
||||
if (tokenized.basename && this.exactMatch(tokenized.basename, token)) {
|
||||
score += this.SCORING_WEIGHTS.FRONTMATTER_TITLE;
|
||||
score += this.SCORING_WEIGHTS.FILENAME;
|
||||
}
|
||||
if (tokenized.frontmatter?.tags && this.exactMatch(tokenized.frontmatter.tags, token)) {
|
||||
score += this.SCORING_WEIGHTS.FRONTMATTER_TAGS;
|
||||
@@ -119,8 +202,19 @@ export class VaultIndexer {
|
||||
if (tokenized.title && this.exactMatch(tokenized.title, token)) {
|
||||
score += this.SCORING_WEIGHTS.TITLE;
|
||||
}
|
||||
if (tokenized.firstParagraph.toLowerCase().includes(token.toLowerCase())) {
|
||||
score += this.SCORING_WEIGHTS.CONTENT;
|
||||
}
|
||||
}
|
||||
return { score };
|
||||
|
||||
// Exact phrase bonus
|
||||
for (const phrase of exactPhrases) {
|
||||
if (fullText.includes(phrase.toLowerCase())) {
|
||||
score += this.SCORING_WEIGHTS.EXACT_PHRASE;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
async getVaultEntries(): Promise<VaultEntry[]> {
|
||||
@@ -132,9 +226,9 @@ export class VaultIndexer {
|
||||
typeof this.vault.cachedRead === 'function'
|
||||
? await this.vault.cachedRead(file)
|
||||
: await this.vault.read(file);
|
||||
const parsed = this.parseMarkdown(content);
|
||||
const parsed = this.parseMarkdown(content, file);
|
||||
entries.push({
|
||||
file: file,
|
||||
file,
|
||||
title: parsed.frontmatter.title || file.basename,
|
||||
frontmatter: parsed.frontmatter,
|
||||
headings: parsed.headings,
|
||||
@@ -150,13 +244,28 @@ export class VaultIndexer {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async searchVault(query: string, limit = 3): Promise<VaultEntry[]> {
|
||||
async searchVault(query: string, limit = 3, options?: SearchOptions): Promise<VaultIndexEntry[]> {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheKey = `query:${query.trim()}:limit:${limit}`;
|
||||
if (this.cache) {
|
||||
const exactPhrases =
|
||||
options?.includeExactPhrase !== false ? this.extractExactPhrases(query) : [];
|
||||
const queryTokens = this.tokenize(query);
|
||||
|
||||
// Try hybrid search: semantic + keyword
|
||||
let semanticResults: VaultIndexEntry[] = [];
|
||||
if (this.vectorStore) {
|
||||
try {
|
||||
semanticResults = await this.vectorStore.search(query, limit * 3);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Semantic search failed: ${errorMessage}`, 'vault-indexer');
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = this.buildCacheKey(query, limit, options);
|
||||
if (this.cache && semanticResults.length === 0) {
|
||||
let cachedResults: string | null = null;
|
||||
try {
|
||||
cachedResults = await this.cache.get(cacheKey);
|
||||
@@ -166,7 +275,7 @@ export class VaultIndexer {
|
||||
if (cachedResults) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsedResults: VaultEntry[] = JSON.parse(cachedResults);
|
||||
const parsedResults: VaultIndexEntry[] = JSON.parse(cachedResults);
|
||||
return parsedResults.slice(0, limit);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
@@ -174,15 +283,22 @@ export class VaultIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
const queryTokens = this.tokenize(query);
|
||||
if (queryTokens.length === 0) {
|
||||
if (queryTokens.length === 0 && exactPhrases.length === 0) {
|
||||
// Only non-token words (e.g. "a", "the") — try exact match fallback
|
||||
if (semanticResults.length > 0) return semanticResults.slice(0, limit);
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await this.getVaultEntries();
|
||||
const now = Date.now();
|
||||
const halfLife = (options?.recencyHalfLifeDays ?? DEFAULT_RECENCY_HALF_LIFE) * DAYS_TO_MS;
|
||||
|
||||
// Precompute exact phrases lowercased
|
||||
const lowerExactPhrases = exactPhrases.map((p) => p.toLowerCase());
|
||||
|
||||
const scored = entries
|
||||
.map((entry) => {
|
||||
const { score } = this.calculateWeightedScore(
|
||||
const keywordScore = this.calculateWeightedScore(
|
||||
{
|
||||
title: entry.title,
|
||||
headings: entry.headings,
|
||||
@@ -191,16 +307,57 @@ export class VaultIndexer {
|
||||
content: entry.content,
|
||||
basename: entry.basename,
|
||||
},
|
||||
queryTokens
|
||||
queryTokens,
|
||||
lowerExactPhrases
|
||||
);
|
||||
|
||||
// Semantic score
|
||||
const semanticEntry = semanticResults.find((s) => s.path === entry.file.path);
|
||||
const semanticScore = semanticEntry ? (semanticEntry.score || 0) * 0.3 : 0;
|
||||
|
||||
// Hybrid score: keyword dominates, semantic adds bonus
|
||||
let score = keywordScore + semanticScore;
|
||||
|
||||
// Folder filter: penalize non-matches
|
||||
if (options?.folder) {
|
||||
const folderLower = options.folder.toLowerCase().replace(/\/$/, '');
|
||||
const entryFolder = entry.file.path.toLowerCase().split('/').slice(0, -1).join('/');
|
||||
if (!entryFolder.startsWith(folderLower) && entryFolder !== folderLower) {
|
||||
score *= 0.1; // Heavy penalty
|
||||
}
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (options?.tag) {
|
||||
const tagLower = options.tag.toLowerCase();
|
||||
const entryTags = (entry.frontmatter.tags ?? '').toLowerCase();
|
||||
if (!entryTags.includes(tagLower)) {
|
||||
score *= 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// Recency boost
|
||||
if (options?.recencyBoost !== false && entry.file.stat?.mtime) {
|
||||
const age = now - entry.file.stat.mtime;
|
||||
const recencyMultiplier = 1 + this.SCORING_WEIGHTS.RECENT * Math.exp(-age / halfLife);
|
||||
score *= recencyMultiplier;
|
||||
}
|
||||
|
||||
return { ...entry, score };
|
||||
})
|
||||
.filter((e) => e.score > 0);
|
||||
.filter((e) => e.score > 0.01);
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const results = scored.slice(0, limit);
|
||||
const results: VaultIndexEntry[] = scored.slice(0, limit).map((e) => ({
|
||||
path: e.file.path,
|
||||
title: e.title,
|
||||
content: e.content,
|
||||
score: e.score,
|
||||
tags: e.frontmatter?.tags,
|
||||
mtime: e.file.stat?.mtime,
|
||||
}));
|
||||
|
||||
if (this.cache) {
|
||||
if (this.cache && semanticResults.length === 0) {
|
||||
try {
|
||||
await this.cache.put(cacheKey, JSON.stringify(results));
|
||||
} catch (error) {
|
||||
@@ -215,6 +372,28 @@ export class VaultIndexer {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts quoted exact phrases from a query.
|
||||
*/
|
||||
private extractExactPhrases(query: string): string[] {
|
||||
const phrases: string[] = [];
|
||||
const quoteRegex = /"([^"]+)"/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = quoteRegex.exec(query)) !== null) {
|
||||
phrases.push(match[1]);
|
||||
}
|
||||
return phrases;
|
||||
}
|
||||
|
||||
private buildCacheKey(query: string, limit: number, options?: SearchOptions): string {
|
||||
const parts = [`query:${query.trim()}:limit:${limit}`];
|
||||
if (options?.folder) parts.push(`folder:${options.folder}`);
|
||||
if (options?.tag) parts.push(`tag:${options.tag}`);
|
||||
if (options?.recencyBoost === false) parts.push('norecency');
|
||||
if (options?.includeExactPhrase === false) parts.push('noexact');
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
private stemToken(token: string): string {
|
||||
if (token.endsWith('ing') && token.length > 4) return token.slice(0, -3);
|
||||
if (token.endsWith('ed') && token.length > 3) return token.slice(0, -2);
|
||||
@@ -230,35 +409,72 @@ export class VaultIndexer {
|
||||
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
||||
}
|
||||
|
||||
private parseMarkdown(content: string) {
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||
const frontmatterMatch = content.match(frontmatterRegex);
|
||||
const frontmatter: ParsedFrontmatter = {};
|
||||
if (frontmatterMatch) {
|
||||
private parseMarkdown(content: string, file?: TFile) {
|
||||
let frontmatter: ParsedFrontmatter = {};
|
||||
let title = '';
|
||||
let headings: string[] = [];
|
||||
|
||||
// Use metadataCache when available for accurate frontmatter and headings parsing
|
||||
if (this.app && file) {
|
||||
try {
|
||||
const lines = frontmatterMatch[1].trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
if (!key) continue;
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key.trim() === 'title' && value) frontmatter.title = value;
|
||||
else if (key.trim() === 'tags' && value) frontmatter.tags = value;
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
if (cache?.frontmatter) {
|
||||
const fm = cache.frontmatter as Record<string, unknown>;
|
||||
if (typeof fm.title === 'string') frontmatter.title = fm.title;
|
||||
const rawTags = fm.tags;
|
||||
if (Array.isArray(rawTags)) {
|
||||
frontmatter.tags = rawTags.map(String).join(', ');
|
||||
} else if (typeof rawTags === 'string') {
|
||||
frontmatter.tags = rawTags;
|
||||
}
|
||||
}
|
||||
if (cache?.headings) {
|
||||
headings = cache.headings.map((h) => h.heading);
|
||||
}
|
||||
if (headings.length > 0) {
|
||||
title = headings[0];
|
||||
}
|
||||
} catch {
|
||||
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
||||
// Fall back to regex parsing below
|
||||
}
|
||||
}
|
||||
|
||||
const titleMatch = content.match(/^# (.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : '';
|
||||
|
||||
const headings: string[] = [];
|
||||
const headingRegex = /^#{1,6} (.+)$/gm;
|
||||
let headingMatch;
|
||||
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
||||
headings.push(headingMatch[1]);
|
||||
// Fallback regex parsing for frontmatter when metadataCache is unavailable
|
||||
if (Object.keys(frontmatter).length === 0) {
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||
const frontmatterMatch = content.match(frontmatterRegex);
|
||||
if (frontmatterMatch) {
|
||||
try {
|
||||
const lines = frontmatterMatch[1].trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
if (!key) continue;
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key.trim() === 'title' && value) frontmatter.title = value;
|
||||
else if (key.trim() === 'tags' && value) frontmatter.tags = value;
|
||||
}
|
||||
} catch {
|
||||
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback regex parsing for title
|
||||
if (!title) {
|
||||
const titleMatch = content.match(/^# (.+)$/m);
|
||||
title = titleMatch ? titleMatch[1] : '';
|
||||
}
|
||||
|
||||
// Fallback regex parsing for headings
|
||||
if (headings.length === 0) {
|
||||
const headingRegex = /^#{1,6} (.+)$/gm;
|
||||
let headingMatch;
|
||||
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
||||
headings.push(headingMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
const bodyWithoutFrontmatter = frontmatterMatch
|
||||
? content.substring(frontmatterMatch[0].length)
|
||||
: content;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/vault-vector-store.ts
|
||||
|
||||
import { ChromaClient, Collection } from 'chromadb';
|
||||
import { TFile } from 'obsidian';
|
||||
import { VaultIndexConfig, VaultIndexEntry } from './types';
|
||||
import { Logger } from './utils';
|
||||
import { ContentVectorizer } from './indexing-pipeline/vectorization';
|
||||
import { ContentExtractor } from './indexing-pipeline/extraction';
|
||||
import { ContentNormalizer } from './indexing-pipeline/normalization';
|
||||
|
||||
export class VaultVectorStore {
|
||||
private client: ChromaClient | null = null;
|
||||
private collection: Collection | null = null;
|
||||
private config: VaultIndexConfig;
|
||||
private ollamaURL: string;
|
||||
private vectorizer: ContentVectorizer;
|
||||
private extractor: ContentExtractor;
|
||||
private normalizer: ContentNormalizer;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(ollamaURL: string, config: VaultIndexConfig) {
|
||||
this.ollamaURL = ollamaURL.replace(/\/+$/, '');
|
||||
this.config = config;
|
||||
this.vectorizer = new ContentVectorizer({
|
||||
model: config.embeddingModel,
|
||||
ollamaUrl: this.ollamaURL,
|
||||
});
|
||||
this.extractor = new ContentExtractor();
|
||||
this.normalizer = new ContentNormalizer();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.config.enabled || this.isInitialized) return;
|
||||
|
||||
try {
|
||||
const rawURL = this.config.chromaURL?.trim() || 'http://localhost:8000';
|
||||
const chromaURL = rawURL.includes('://') ? rawURL : 'http://localhost:8000';
|
||||
this.client = new ChromaClient({ path: chromaURL });
|
||||
this.collection = await this.client.getOrCreateCollection({
|
||||
name: this.config.collectionName,
|
||||
metadata: { 'hnsw:space': 'cosine' },
|
||||
});
|
||||
this.isInitialized = true;
|
||||
Logger.info(
|
||||
`Vault vector store initialized: ${this.config.collectionName}`,
|
||||
'vault-vector-store'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.error(
|
||||
`Failed to initialize vault vector store: ${errorMessage}`,
|
||||
'vault-vector-store'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a single vault file by generating an embedding and storing it in ChromaDB.
|
||||
* Optionally accepts cached metadata from Obsidian's metadataCache.
|
||||
*/
|
||||
async indexFile(
|
||||
file: TFile,
|
||||
content: string,
|
||||
cache?: {
|
||||
frontmatter?: Record<string, unknown>;
|
||||
headings?: Array<{ heading: string; level: number }>;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!this.collection || !this.config.enabled) return;
|
||||
if (!content.trim()) {
|
||||
// Remove empty files from index if they exist
|
||||
await this.deleteFile(file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const extracted = this.extractor.extractFromFile(
|
||||
{ basename: file.basename, path: file.path },
|
||||
content,
|
||||
cache
|
||||
);
|
||||
const normalized = this.normalizer.normalize(extracted);
|
||||
|
||||
const chunk = {
|
||||
id: file.path,
|
||||
path: file.path,
|
||||
title: normalized.title,
|
||||
content: normalized.content,
|
||||
tokens: normalized.tokens,
|
||||
headings: normalized.headings,
|
||||
frontmatter: normalized.frontmatter,
|
||||
firstParagraph: normalized.firstParagraph,
|
||||
wordCount: normalized.wordCount,
|
||||
chunkIndex: 0,
|
||||
chunkSize: 1,
|
||||
};
|
||||
|
||||
const embedding = await this.vectorizer.vectorize(chunk);
|
||||
if (embedding.length === 0) {
|
||||
Logger.warn(`Empty embedding for ${file.path}, skipping index`, 'vault-vector-store');
|
||||
return;
|
||||
}
|
||||
|
||||
// Upsert by path so re-indexing updates rather than duplicates
|
||||
await this.collection.upsert({
|
||||
ids: [file.path],
|
||||
documents: [this.createDocumentText(normalized)],
|
||||
embeddings: [embedding],
|
||||
metadatas: [
|
||||
{
|
||||
path: file.path,
|
||||
title: normalized.title,
|
||||
tags:
|
||||
typeof normalized.frontmatter.tags === 'string' ? normalized.frontmatter.tags : '',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Failed to index file ${file.path}: ${errorMessage}`, 'vault-vector-store');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the vector index.
|
||||
*/
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
if (!this.collection || !this.config.enabled) return;
|
||||
|
||||
try {
|
||||
await this.collection.delete({ ids: [path] });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(
|
||||
`Failed to delete file ${path} from index: ${errorMessage}`,
|
||||
'vault-vector-store'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform semantic search against the vault index.
|
||||
*/
|
||||
async search(query: string, limit = 3): Promise<VaultIndexEntry[]> {
|
||||
if (!this.collection || !this.config.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryEmbedding = await this.vectorizer.vectorize({
|
||||
id: 'query',
|
||||
path: 'query',
|
||||
title: query,
|
||||
content: query,
|
||||
tokens: query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 1),
|
||||
headings: [query],
|
||||
frontmatter: {},
|
||||
firstParagraph: query,
|
||||
wordCount: query.split(/\s+/).length,
|
||||
chunkIndex: 0,
|
||||
chunkSize: 1,
|
||||
});
|
||||
|
||||
if (queryEmbedding.length === 0) {
|
||||
Logger.warn('Empty query embedding, skipping semantic search', 'vault-vector-store');
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await this.collection.query({
|
||||
queryEmbeddings: [queryEmbedding],
|
||||
nResults: limit,
|
||||
});
|
||||
|
||||
if (!results.ids[0] || results.ids[0].length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: VaultIndexEntry[] = [];
|
||||
for (let i = 0; i < results.ids[0].length; i++) {
|
||||
const distance = results.distances?.[0]?.[i] ?? 0;
|
||||
// Cosine distance to score: closer to 1 is better
|
||||
const score = 1 - distance;
|
||||
if (score < this.config.similarityThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = results.metadatas?.[0]?.[i] as Record<string, string> | undefined;
|
||||
const document = results.documents?.[0]?.[i] as string | undefined;
|
||||
|
||||
entries.push({
|
||||
path: metadata?.path || String(results.ids[0][i]),
|
||||
title: metadata?.title || 'Untitled',
|
||||
content: document || '',
|
||||
score,
|
||||
tags: metadata?.tags,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
entries.sort((a, b) => b.score - a.score);
|
||||
return entries.slice(0, limit);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.warn(`Semantic search failed: ${errorMessage}`, 'vault-vector-store');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire vault index.
|
||||
*/
|
||||
async clearIndex(): Promise<void> {
|
||||
if (!this.client || !this.config.enabled) return;
|
||||
|
||||
try {
|
||||
await this.client.deleteCollection({ name: this.config.collectionName });
|
||||
this.collection = null;
|
||||
this.isInitialized = false;
|
||||
Logger.info('Vault index cleared', 'vault-vector-store');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
Logger.error(`Failed to clear vault index: ${errorMessage}`, 'vault-vector-store');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of indexed documents.
|
||||
*/
|
||||
async getIndexedCount(): Promise<number> {
|
||||
if (!this.collection || !this.config.enabled) return 0;
|
||||
try {
|
||||
const result = await this.collection.count();
|
||||
return result;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private createDocumentText(normalized: ReturnType<ContentNormalizer['normalize']>): string {
|
||||
const parts: string[] = [];
|
||||
if (normalized.title) parts.push(`Title: ${normalized.title}`);
|
||||
if (normalized.firstParagraph) parts.push(normalized.firstParagraph);
|
||||
if (normalized.headings.length > 0) parts.push(normalized.headings.join('\n'));
|
||||
parts.push(normalized.content.substring(0, 1000));
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export class WorkflowEngine {
|
||||
private conversationStateManager: ConversationStateManager;
|
||||
private maxSteps: number;
|
||||
private maxWorkflowDuration: number;
|
||||
private availableTools: OllamaTool[] = [];
|
||||
|
||||
constructor(
|
||||
vault: Vault,
|
||||
@@ -58,7 +59,8 @@ export class WorkflowEngine {
|
||||
}
|
||||
) {
|
||||
this.vaultIndexer = new VaultIndexer(vault);
|
||||
this.toolExecutor = new ToolExecutor(vault, app);
|
||||
this.vaultIndexer.setApp(app);
|
||||
this.toolExecutor = new ToolExecutor(vault, app, undefined, this.vaultIndexer);
|
||||
this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig);
|
||||
this.conversationStateManager = new ConversationStateManager();
|
||||
this.maxSteps = options?.maxSteps ?? 20;
|
||||
@@ -199,6 +201,7 @@ export class WorkflowEngine {
|
||||
availableTools?: OllamaTool[]
|
||||
): Promise<WorkflowExecutionResult> {
|
||||
Logger.info(`Generating workflow from query: ${userQuery}`, 'workflow-engine');
|
||||
this.availableTools = availableTools ?? [];
|
||||
|
||||
// First, ask the LLM to generate a workflow plan
|
||||
const workflow = await this.generateWorkflowFromQuery(userQuery, availableTools);
|
||||
@@ -290,10 +293,10 @@ Rules:
|
||||
|
||||
// Extract JSON from the response (handle markdown code blocks)
|
||||
const jsonMatch =
|
||||
content.match(/\```(?:json)?\s*([\s\S]*?)\```/) ?? content.match(/\{[\s\S]*\}/);
|
||||
content.match(/```(?:json)?\s*([\s\S]*?)```/) ?? content.match(/\{[\s\S]*\}/);
|
||||
const jsonString = jsonMatch ? jsonMatch[1] : content;
|
||||
|
||||
const parsed = safeParseJson(jsonString) as unknown;
|
||||
const parsed = safeParseJson(jsonString);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
Logger.error('Invalid workflow JSON from LLM', 'workflow-engine');
|
||||
return null;
|
||||
@@ -354,7 +357,7 @@ Rules:
|
||||
data = this.executeFormatStep(interpolatedConfig as FormatStepConfig, context);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown step type: ${step.type}`);
|
||||
throw new Error(`Unknown step type: ${String(step.type)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -406,7 +409,7 @@ Rules:
|
||||
});
|
||||
|
||||
// Determine if we should include tools
|
||||
const tools = config.includeToolCalls ? [] : [];
|
||||
const tools = config.includeToolCalls ? this.availableTools : [];
|
||||
|
||||
const response = await this.ollamaClient.chat(messages, tools);
|
||||
return response.content ?? '';
|
||||
@@ -422,17 +425,17 @@ Rules:
|
||||
// Apply tag filter if specified
|
||||
const filtered = config.tagFilter
|
||||
? entries.filter((entry) => {
|
||||
const tags = entry.frontmatter?.tags ?? '';
|
||||
const tags = entry.tags ?? '';
|
||||
return tags.toLowerCase().includes(config.tagFilter!.toLowerCase());
|
||||
})
|
||||
: entries;
|
||||
|
||||
return filtered.map((entry) => ({
|
||||
path: entry.file.path,
|
||||
path: entry.path,
|
||||
title: entry.title,
|
||||
content: entry.content,
|
||||
score: entry.score,
|
||||
tags: entry.frontmatter?.tags,
|
||||
tags: entry.tags,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -502,7 +505,7 @@ Rules:
|
||||
* Interpolate variables in a string.
|
||||
*/
|
||||
private interpolateString(input: string, variables: Map<string, unknown>): string {
|
||||
return input.replace(VARIABLE_PATTERN, (_match, variablePath) => {
|
||||
return input.replace(VARIABLE_PATTERN, (_match: string, variablePath: string) => {
|
||||
const value = this.resolveVariable(variablePath, variables);
|
||||
if (value === undefined) {
|
||||
// Keep the original placeholder if variable not found
|
||||
@@ -515,6 +518,18 @@ Rules:
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Fallback for symbols, functions, etc.
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
:root {
|
||||
--ollama-user-bg: var(--background-modifier-form-field);
|
||||
--ollama-assistant-bg: var(--background-primary-alt);
|
||||
--ollama-border: var(--background-modifier-border);
|
||||
--ollama-radius: var(--radius-m);
|
||||
--ollama-gap: var(--size-4-2);
|
||||
--ollama-log-debug: var(--text-muted);
|
||||
--ollama-log-info: var(--text-accent);
|
||||
--ollama-log-warn: var(--text-warning);
|
||||
--ollama-log-error: var(--text-error);
|
||||
--ollama-log-bg: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
/* Root chat view layout: flex column with input locked at bottom */
|
||||
.ollama-chat-view-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ollama-new-chat-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ollama-chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ollama-gap);
|
||||
padding: var(--size-4-2);
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.ollama-input-container {
|
||||
display: flex;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-2);
|
||||
border-top: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-primary);
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ollama-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
max-width: 90%;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.ollama-message-user {
|
||||
align-self: flex-end;
|
||||
background-color: var(--ollama-user-bg);
|
||||
}
|
||||
|
||||
.ollama-message-assistant {
|
||||
align-self: flex-start;
|
||||
background-color: var(--ollama-assistant-bg);
|
||||
}
|
||||
|
||||
.ollama-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
font-size: var(--font-smallest);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ollama-message-role::before {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: var(--size-4-1);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ollama-message-user .ollama-message-role::before {
|
||||
content: '👤';
|
||||
}
|
||||
|
||||
.ollama-message-assistant .ollama-message-role::before {
|
||||
content: '🤖';
|
||||
}
|
||||
|
||||
.ollama-message-content {
|
||||
line-height: var(--line-height-normal);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.ollama-new-chat-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
}
|
||||
|
||||
.ollama-new-chat-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
font-size: var(--font-smallest);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ollama-new-chat-button:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.ollama-new-chat-button::before {
|
||||
content: '➕';
|
||||
}
|
||||
|
||||
.ollama-input {
|
||||
flex: 1;
|
||||
min-height: 4rem;
|
||||
max-height: 12rem;
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: var(--font-ui-small);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.ollama-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent-translucent);
|
||||
}
|
||||
|
||||
.ollama-send-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--size-4-1) var(--size-4-3);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: none;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ollama-send-button:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.ollama-send-button:active {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* Stop button */
|
||||
.ollama-stop-button {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--size-4-1) var(--size-4-3);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: none;
|
||||
background-color: var(--text-error);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ollama-stop-button:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.ollama-stop-button:active {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* Thinking indicator */
|
||||
.ollama-thinking-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ollama-thinking-indicator::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
border: 2px solid var(--text-muted);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ollama-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ollama-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Proposed Actions Preview */
|
||||
.ollama-proposed-actions {
|
||||
margin-top: var(--size-4-2);
|
||||
padding: var(--size-4-2);
|
||||
border: 1px solid var(--ollama-border);
|
||||
border-radius: var(--ollama-radius);
|
||||
background-color: var(--background-primary);
|
||||
}
|
||||
|
||||
.ollama-proposed-actions-header {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-small);
|
||||
margin-bottom: var(--size-4-2);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ollama-proposed-action {
|
||||
margin-bottom: var(--size-4-2);
|
||||
padding: var(--size-4-1);
|
||||
border: 1px solid var(--ollama-border);
|
||||
border-radius: var(--ollama-radius);
|
||||
background-color: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
.ollama-action-description {
|
||||
font-weight: var(--font-medium);
|
||||
font-size: var(--font-ui-small);
|
||||
margin-bottom: var(--size-4-1);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ollama-action-diff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-1);
|
||||
}
|
||||
|
||||
.ollama-diff-before,
|
||||
.ollama-diff-after {
|
||||
margin: 0;
|
||||
padding: var(--size-4-1);
|
||||
border-radius: var(--ollama-radius);
|
||||
font-size: var(--font-smallest);
|
||||
font-family: var(--font-monospace);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 8rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ollama-diff-before {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ollama-diff-after {
|
||||
background-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ollama-action-buttons {
|
||||
display: flex;
|
||||
gap: var(--size-4-1);
|
||||
margin-top: var(--size-4-2);
|
||||
}
|
||||
|
||||
.ollama-apply-button {
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: none;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.ollama-apply-button:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.ollama-cancel-button {
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ollama-cancel-button:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
/* Agent Mode Selector */
|
||||
.ollama-mode-selector {
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ollama-mode-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Model Selector */
|
||||
.ollama-model-selector {
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ollama-model-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Chat History Selector */
|
||||
.ollama-history-selector {
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ollama-history-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.ollama-history-delete-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
font-size: var(--font-smallest);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ollama-history-delete-button:hover {
|
||||
background-color: var(--background-modifier-error-hover);
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
/* Show Logs Button */
|
||||
.ollama-show-logs-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
font-size: var(--font-smallest);
|
||||
border-radius: var(--ollama-radius);
|
||||
border: 1px solid var(--ollama-border);
|
||||
background-color: var(--background-modifier-form-field);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.ollama-show-logs-button:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
/* Logs Panel */
|
||||
.ollama-logs-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
background-color: var(--ollama-log-bg);
|
||||
border-top: 1px solid var(--ollama-border);
|
||||
border-bottom: 1px solid var(--ollama-border);
|
||||
font-family: var(--font-monospace);
|
||||
font-size: var(--font-smallest);
|
||||
}
|
||||
|
||||
.ollama-log-row {
|
||||
display: flex;
|
||||
gap: var(--size-4-1);
|
||||
align-items: baseline;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.ollama-log-time {
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ollama-log-level {
|
||||
flex-shrink: 0;
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
.ollama-log-level-debug {
|
||||
color: var(--ollama-log-debug);
|
||||
}
|
||||
|
||||
.ollama-log-level-info {
|
||||
color: var(--ollama-log-info);
|
||||
}
|
||||
|
||||
.ollama-log-level-warn {
|
||||
color: var(--ollama-log-warn);
|
||||
}
|
||||
|
||||
.ollama-log-level-error {
|
||||
color: var(--ollama-log-error);
|
||||
}
|
||||
|
||||
.ollama-log-category {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ollama-log-message {
|
||||
color: var(--text-normal);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Activity Indicator */
|
||||
.ollama-activity-indicator {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1);
|
||||
padding: var(--size-4-1) var(--size-4-1);
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ollama-activity-dot {
|
||||
display: inline-block;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--interactive-accent);
|
||||
animation: ollama-pulse 1.2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ollama-activity-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes ollama-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled input/send during activity */
|
||||
.ollama-input:disabled,
|
||||
.ollama-send-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { ActionPreviewBuilder, isWriteTool } from '../src/action-preview-builder';
|
||||
import { TFile } from 'obsidian';
|
||||
import { ToolCall } from '../src/types';
|
||||
|
||||
// Mock Obsidian module
|
||||
jest.mock('obsidian', () => {
|
||||
class TFile {}
|
||||
return {
|
||||
Vault: jest.fn(),
|
||||
App: jest.fn(),
|
||||
Notice: jest.fn(),
|
||||
TFile,
|
||||
};
|
||||
});
|
||||
|
||||
describe('isWriteTool', () => {
|
||||
it('should return true for write tools', () => {
|
||||
expect(isWriteTool('create_note')).toBe(true);
|
||||
expect(isWriteTool('create_file')).toBe(true);
|
||||
expect(isWriteTool('append_to_note')).toBe(true);
|
||||
expect(isWriteTool('replace_note_section')).toBe(true);
|
||||
expect(isWriteTool('update_frontmatter')).toBe(true);
|
||||
expect(isWriteTool('rename_note')).toBe(true);
|
||||
expect(isWriteTool('move_note')).toBe(true);
|
||||
expect(isWriteTool('delete_note')).toBe(true);
|
||||
expect(isWriteTool('insert_link')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for read/search tools', () => {
|
||||
expect(isWriteTool('read_vault_file')).toBe(false);
|
||||
expect(isWriteTool('search_vault_files')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown tools', () => {
|
||||
expect(isWriteTool('unknown_tool')).toBe(false);
|
||||
expect(isWriteTool('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionPreviewBuilder', () => {
|
||||
let builder: ActionPreviewBuilder;
|
||||
let mockVault: {
|
||||
getAbstractFileByPath: jest.Mock;
|
||||
cachedRead: jest.Mock;
|
||||
};
|
||||
let mockApp: {
|
||||
metadataCache: {
|
||||
getFileCache: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = {
|
||||
getAbstractFileByPath: jest.fn(),
|
||||
cachedRead: jest.fn().mockResolvedValue(''),
|
||||
};
|
||||
mockApp = {
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
};
|
||||
builder = new ActionPreviewBuilder(mockVault as unknown as any, mockApp as unknown as any);
|
||||
});
|
||||
|
||||
describe('buildPreview', () => {
|
||||
it('should build preview for create_note', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
arguments: JSON.stringify({ path: 'New.md', content: '# Hello' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('create');
|
||||
expect(preview.path).toBe('New.md');
|
||||
expect(preview.preview?.before).toBeUndefined();
|
||||
expect(preview.preview?.after).toBe('# Hello');
|
||||
});
|
||||
|
||||
it('should build preview for append_to_note', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('Existing content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'append_to_note',
|
||||
arguments: JSON.stringify({ path: 'Note.md', content: 'Appended' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('append');
|
||||
expect(preview.preview?.before).toBe('Existing content');
|
||||
expect(preview.preview?.after).toBe('Existing content\nAppended');
|
||||
});
|
||||
|
||||
it('should build preview for replace_note_section using metadataCache', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('# Title\n\n## Section A\nOld\n\n## Section B\nOther');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({
|
||||
headings: [
|
||||
{ heading: 'Title', level: 1, position: { start: { offset: 0 } } },
|
||||
{ heading: 'Section A', level: 2, position: { start: { offset: 9 } } },
|
||||
{ heading: 'Section B', level: 2, position: { start: { offset: 24 } } },
|
||||
],
|
||||
});
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_3',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'replace_note_section',
|
||||
arguments: JSON.stringify({ path: 'Note.md', heading: 'Section A', content: 'New' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('replace_section');
|
||||
expect(preview.preview?.after).toContain('New');
|
||||
expect(preview.preview?.after).not.toContain('Old');
|
||||
});
|
||||
|
||||
it('should build preview for replace_note_section with regex fallback', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('# Title\n\n## Section A\nOld\n\n## Section B\nOther');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_3b',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'replace_note_section',
|
||||
arguments: JSON.stringify({ path: 'Note.md', heading: 'Section A', content: 'New' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('replace_section');
|
||||
expect(preview.preview?.after).toContain('New');
|
||||
expect(preview.preview?.after).not.toContain('Old');
|
||||
});
|
||||
|
||||
it('should build preview for update_frontmatter using metadataCache', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('---\ntitle: Old\n---\nBody');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: { title: 'Old' },
|
||||
});
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_4',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_frontmatter',
|
||||
arguments: JSON.stringify({ path: 'Note.md', fields: { title: 'New' } }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('update_frontmatter');
|
||||
expect(preview.preview?.after).toContain('title: New');
|
||||
});
|
||||
|
||||
it('should build preview for update_frontmatter with regex fallback', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('---\ntitle: Old\n---\nBody');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_4b',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_frontmatter',
|
||||
arguments: JSON.stringify({ path: 'Note.md', fields: { title: 'New' } }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('update_frontmatter');
|
||||
expect(preview.preview?.after).toContain('title: New');
|
||||
});
|
||||
|
||||
it('should build preview for rename_note', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_5',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'rename_note',
|
||||
arguments: JSON.stringify({ oldPath: 'Old.md', newPath: 'New.md' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('rename');
|
||||
expect(preview.preview?.before).toBe('Old.md');
|
||||
expect(preview.preview?.after).toBe('New.md');
|
||||
});
|
||||
|
||||
it('should build preview for move_note', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_6',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'move_note',
|
||||
arguments: JSON.stringify({ path: 'Projects/Note.md', folder: 'Archive' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('move');
|
||||
expect(preview.preview?.before).toBe('Projects/Note.md');
|
||||
expect(preview.preview?.after).toBe('Archive/Note.md');
|
||||
});
|
||||
|
||||
it('should build preview for delete_note', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('File content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_7',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_note',
|
||||
arguments: JSON.stringify({ path: 'Note.md' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('delete');
|
||||
expect(preview.preview?.before).toBe('File content');
|
||||
expect(preview.preview?.after).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should build preview for insert_link without anchor text', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('Source content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_8',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'insert_link',
|
||||
arguments: JSON.stringify({ sourcePath: 'A.md', targetPath: 'B.md' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('insert_link');
|
||||
expect(preview.preview?.after).toContain('[[B.md]]');
|
||||
});
|
||||
|
||||
it('should build preview for insert_link with anchor text', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(new TFile());
|
||||
mockVault.cachedRead.mockResolvedValue('Source content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_9',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'insert_link',
|
||||
arguments: JSON.stringify({ sourcePath: 'A.md', targetPath: 'B.md', anchorText: 'Link' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.preview?.after).toContain('[[B.md|Link]]');
|
||||
});
|
||||
|
||||
it('should handle object arguments directly', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_10',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
arguments: { path: 'Direct.md', content: 'Body' } as unknown as string,
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.path).toBe('Direct.md');
|
||||
expect(preview.preview?.after).toBe('Body');
|
||||
});
|
||||
|
||||
it('should return unknown operation for unrecognized tools', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_11',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'weird_tool',
|
||||
arguments: '{}',
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.operation).toBe('read');
|
||||
expect(preview.description).toContain('weird_tool');
|
||||
});
|
||||
|
||||
it('should handle missing file gracefully for append_to_note', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_12',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'append_to_note',
|
||||
arguments: JSON.stringify({ path: 'Missing.md', content: 'test' }),
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.preview?.before).toBeUndefined();
|
||||
expect(preview.preview?.after).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON arguments gracefully', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_13',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
arguments: 'not json',
|
||||
},
|
||||
};
|
||||
const preview = await builder.buildPreview(call);
|
||||
expect(preview.path).toBe('');
|
||||
expect(preview.preview?.after).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
AgentMode,
|
||||
ALL_AGENT_MODES,
|
||||
DEFAULT_AGENT_MODE,
|
||||
AGENT_MODE_CONFIGS,
|
||||
getAgentModeLabel,
|
||||
modeRequiresPreview,
|
||||
getSystemPromptForMode,
|
||||
filterToolsForMode,
|
||||
} from '../src/agent-modes';
|
||||
import { OllamaTool } from '../src/types';
|
||||
|
||||
describe('Agent Modes', () => {
|
||||
const allTools: OllamaTool[] = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_vault_file',
|
||||
description: 'Read',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
description: 'Search',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
description: 'Create',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_frontmatter',
|
||||
description: 'Update frontmatter',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_note',
|
||||
description: 'Delete',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('constants', () => {
|
||||
it('should define all expected modes', () => {
|
||||
expect(ALL_AGENT_MODES).toEqual(['ask', 'edit', 'organize', 'research', 'workflow']);
|
||||
});
|
||||
|
||||
it('should have default mode ask', () => {
|
||||
expect(DEFAULT_AGENT_MODE).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentModeLabel', () => {
|
||||
it('should return labels for known modes', () => {
|
||||
expect(getAgentModeLabel('ask')).toBe('Ask');
|
||||
expect(getAgentModeLabel('edit')).toBe('Edit');
|
||||
expect(getAgentModeLabel('organize')).toBe('Organize');
|
||||
expect(getAgentModeLabel('research')).toBe('Research');
|
||||
expect(getAgentModeLabel('workflow')).toBe('Workflow');
|
||||
});
|
||||
|
||||
it('should fallback to raw mode name for unknown modes', () => {
|
||||
expect(getAgentModeLabel('unknown' as AgentMode)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modeRequiresPreview', () => {
|
||||
it('should require preview for edit and organize', () => {
|
||||
expect(modeRequiresPreview('edit')).toBe(true);
|
||||
expect(modeRequiresPreview('organize')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not require preview for ask, research, workflow', () => {
|
||||
expect(modeRequiresPreview('ask')).toBe(false);
|
||||
expect(modeRequiresPreview('research')).toBe(false);
|
||||
expect(modeRequiresPreview('workflow')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemPromptForMode', () => {
|
||||
it('should return a non-empty prompt for each mode', () => {
|
||||
for (const mode of ALL_AGENT_MODES) {
|
||||
const prompt = getSystemPromptForMode(mode);
|
||||
expect(typeof prompt).toBe('string');
|
||||
expect(prompt.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fallback to ask prompt for unknown mode', () => {
|
||||
const askPrompt = getSystemPromptForMode('ask');
|
||||
const fallback = getSystemPromptForMode('unknown' as AgentMode);
|
||||
expect(fallback).toBe(askPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterToolsForMode', () => {
|
||||
it('ask mode should only allow read/search tools', () => {
|
||||
const filtered = filterToolsForMode(allTools, 'ask');
|
||||
const names = filtered.map((t) => t.function.name);
|
||||
expect(names).toContain('read_vault_file');
|
||||
expect(names).toContain('search_vault_files');
|
||||
expect(names).not.toContain('create_note');
|
||||
expect(names).not.toContain('delete_note');
|
||||
});
|
||||
|
||||
it('edit mode should allow all tools', () => {
|
||||
const filtered = filterToolsForMode(allTools, 'edit');
|
||||
const names = filtered.map((t) => t.function.name);
|
||||
expect(names).toContain('read_vault_file');
|
||||
expect(names).toContain('create_note');
|
||||
expect(names).toContain('delete_note');
|
||||
});
|
||||
|
||||
it('organize mode should allow organize tools but not delete', () => {
|
||||
const filtered = filterToolsForMode(allTools, 'organize');
|
||||
const names = filtered.map((t) => t.function.name);
|
||||
expect(names).toContain('read_vault_file');
|
||||
expect(names).toContain('search_vault_files');
|
||||
expect(names).toContain('update_frontmatter');
|
||||
expect(names).not.toContain('delete_note');
|
||||
expect(names).not.toContain('create_note');
|
||||
});
|
||||
|
||||
it('research mode should only allow read/search tools', () => {
|
||||
const filtered = filterToolsForMode(allTools, 'research');
|
||||
const names = filtered.map((t) => t.function.name);
|
||||
expect(names).toContain('read_vault_file');
|
||||
expect(names).toContain('search_vault_files');
|
||||
expect(names).not.toContain('create_note');
|
||||
});
|
||||
|
||||
it('workflow mode should return no tools', () => {
|
||||
const filtered = filterToolsForMode(allTools, 'workflow');
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unknown mode should return all tools', () => {
|
||||
const filtered = filterToolsForMode(allTools, 'unknown' as AgentMode);
|
||||
expect(filtered).toEqual(allTools);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AGENT_MODE_CONFIGS', () => {
|
||||
it('should have a config for every mode', () => {
|
||||
for (const mode of ALL_AGENT_MODES) {
|
||||
expect(AGENT_MODE_CONFIGS[mode]).toBeDefined();
|
||||
expect(AGENT_MODE_CONFIGS[mode].label).toBeDefined();
|
||||
expect(AGENT_MODE_CONFIGS[mode].description).toBeDefined();
|
||||
expect(AGENT_MODE_CONFIGS[mode].systemPrompt).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
AutoTagger,
|
||||
AutoLinker,
|
||||
normalizeTag,
|
||||
buildTagVocabulary,
|
||||
normalizeTagsAgainstVocabulary,
|
||||
} from '../src/auto-organizer';
|
||||
import { OllamaClient } from '../src/ollama-client';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../src/ollama-client');
|
||||
jest.mock('../src/utils', () => ({
|
||||
Logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Obsidian
|
||||
const mockModify = jest.fn();
|
||||
const mockCachedRead = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockGetMarkdownFiles = jest.fn();
|
||||
|
||||
const createMockVault = () => ({
|
||||
getMarkdownFiles: mockGetMarkdownFiles,
|
||||
cachedRead: mockCachedRead,
|
||||
read: mockRead,
|
||||
modify: mockModify,
|
||||
});
|
||||
|
||||
const createMockApp = () => ({
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
describe('AutoTagger', () => {
|
||||
let tagger: AutoTagger;
|
||||
let mockVault: ReturnType<typeof createMockVault>;
|
||||
let mockApp: ReturnType<typeof createMockApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockVault = createMockVault();
|
||||
mockApp = createMockApp();
|
||||
tagger = new AutoTagger(mockVault as any, mockApp as any, 'http://localhost:11434', 'llama3', {
|
||||
enabled: true,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate: 'Tags for {{title}}: {{content}}',
|
||||
dryRun: false,
|
||||
targetFolder: '',
|
||||
normalizeTags: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUntaggedNotes', () => {
|
||||
it('should return files without frontmatter', async () => {
|
||||
const files = [{ path: 'note1.md' }, { path: 'note2.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({ frontmatter: { tags: 'existing' } });
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('note1.md');
|
||||
});
|
||||
|
||||
it('should return files with empty tags', async () => {
|
||||
const files = [{ path: 'note1.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: '' } });
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return files with empty array tags', async () => {
|
||||
const files = [{ path: 'note1.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: [] } });
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should skip files with existing tags', async () => {
|
||||
const files = [{ path: 'note1.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: ['ai', 'ml'] } });
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should respect targetFolder', async () => {
|
||||
tagger.updateConfig({ ...(tagger as any).config, targetFolder: 'Projects' });
|
||||
const files = [
|
||||
{ path: 'Projects/note1.md' },
|
||||
{ path: 'Archive/note2.md' },
|
||||
{ path: 'Projects/Sub/note3.md' },
|
||||
] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
||||
|
||||
const result = await tagger.getUntaggedNotes();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f: any) => f.path)).toEqual([
|
||||
'Projects/note1.md',
|
||||
'Projects/Sub/note3.md',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTagResponse', () => {
|
||||
it('should parse comma-separated tags', () => {
|
||||
const tags = (tagger as any).parseTagResponse('ai, machine-learning, obsidian');
|
||||
expect(tags).toEqual(['ai', 'machine-learning', 'obsidian']);
|
||||
});
|
||||
|
||||
it('should clean up hashtags and quotes', () => {
|
||||
const tags = (tagger as any).parseTagResponse('#ai, "machine learning", #obsidian');
|
||||
expect(tags).toEqual(['ai', 'machine learning', 'obsidian']);
|
||||
});
|
||||
|
||||
it('should limit to max tags', () => {
|
||||
const tags = (tagger as any).parseTagResponse('a, b, c, d, e, f, g');
|
||||
expect(tags).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should filter empty tags', () => {
|
||||
const tags = (tagger as any).parseTagResponse('ai,, , ml');
|
||||
expect(tags).toEqual(['ai', 'ml']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTags', () => {
|
||||
it('should add frontmatter to note without it', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockRead.mockResolvedValue('Just content');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
||||
|
||||
await tagger.applyTags(file, ['ai', 'ml']);
|
||||
|
||||
expect(mockModify).toHaveBeenCalledWith(file, '---\ntags: ai, ml\n---\n\nJust content');
|
||||
});
|
||||
|
||||
it('should update existing frontmatter with tags', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockRead.mockResolvedValue('---\ndate: 2024-01-01\n---\nContent');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
|
||||
|
||||
await tagger.applyTags(file, ['ai']);
|
||||
|
||||
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: ai'));
|
||||
});
|
||||
|
||||
it('should replace existing tags line', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockRead.mockResolvedValue('---\ntags: old\n---\nContent');
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: 'old' } });
|
||||
|
||||
await tagger.applyTags(file, ['new']);
|
||||
|
||||
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: new'));
|
||||
expect(mockModify).not.toHaveBeenCalledWith(file, expect.stringContaining('tags: old'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should return early when disabled', async () => {
|
||||
tagger.updateConfig({ ...tagger['config'], enabled: false });
|
||||
const result = await tagger.run();
|
||||
expect(result.tagged).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip notes that are too short', async () => {
|
||||
const files = [{ path: 'note.md' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
||||
mockRead.mockResolvedValue('Short');
|
||||
|
||||
const result = await tagger.run();
|
||||
expect(result.skipped).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return dry-run proposals when dryRun is enabled', async () => {
|
||||
tagger.updateConfig({ ...tagger['config'], dryRun: true });
|
||||
const files = [{ path: 'note.md', basename: 'Note' }] as any[];
|
||||
mockGetMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue(null);
|
||||
mockRead.mockResolvedValue('A longer note about AI and machine learning with lots of interesting content that exceeds the minimum length requirement for tagging.');
|
||||
|
||||
// Mock the ollamaClient on the tagger instance
|
||||
(tagger as any).ollamaClient = {
|
||||
chat: jest.fn().mockResolvedValue({
|
||||
content: 'ai, machine-learning',
|
||||
role: 'assistant',
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await tagger.run();
|
||||
expect(result.dryRun).toBeDefined();
|
||||
expect(result.dryRun!.length).toBe(1);
|
||||
expect(result.dryRun![0].proposedTags).toContain('ai');
|
||||
expect(result.dryRun![0].proposedTags).toContain('machine-learning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTag', () => {
|
||||
it('should lowercase and hyphenate tags', () => {
|
||||
expect(normalizeTag('Machine Learning')).toBe('machine-learning');
|
||||
expect(normalizeTag('AI')).toBe('ai');
|
||||
expect(normalizeTag('obsidian-plugin')).toBe('obsidian-plugin');
|
||||
});
|
||||
|
||||
it('should strip special characters', () => {
|
||||
expect(normalizeTag('C++')).toBe('c');
|
||||
expect(normalizeTag('Node.js')).toBe('nodejs');
|
||||
});
|
||||
|
||||
it('should trim dashes', () => {
|
||||
expect(normalizeTag('-leading')).toBe('leading');
|
||||
expect(normalizeTag('trailing-')).toBe('trailing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTagVocabulary', () => {
|
||||
it('should collect existing tags from vault frontmatter', () => {
|
||||
const mockFiles = [{ path: 'a.md' }, { path: 'b.md' }] as any[];
|
||||
const vault = createMockVault();
|
||||
vault.getMarkdownFiles.mockReturnValue(mockFiles);
|
||||
;(vault as any).app = {
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockImplementation((f: any) => {
|
||||
if (f.path === 'a.md') return { frontmatter: { tags: ['machine-learning', 'ai'] } };
|
||||
if (f.path === 'b.md') return { frontmatter: { tags: 'obsidian-plugin' } };
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const vocab = buildTagVocabulary(vault as any, (vault as any).app);
|
||||
expect(vocab.get('machine-learning')).toBe('machine-learning');
|
||||
expect(vocab.get('ai')).toBe('ai');
|
||||
expect(vocab.get('obsidian-plugin')).toBe('obsidian-plugin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTagsAgainstVocabulary', () => {
|
||||
it('should prefer canonical forms from vocabulary', () => {
|
||||
const vocab = new Map([
|
||||
['machine-learning', 'machine-learning'],
|
||||
['obsidian', 'Obsidian'],
|
||||
]);
|
||||
const result = normalizeTagsAgainstVocabulary(
|
||||
['Machine Learning', 'obsidian', 'new-tag'],
|
||||
vocab
|
||||
);
|
||||
expect(result).toContain('machine-learning');
|
||||
expect(result).toContain('Obsidian');
|
||||
expect(result).toContain('new-tag');
|
||||
});
|
||||
|
||||
it('should deduplicate normalized tags', () => {
|
||||
const vocab = new Map();
|
||||
const result = normalizeTagsAgainstVocabulary(['ai', 'AI', 'Ai'], vocab);
|
||||
expect(result).toEqual(['ai']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AutoLinker', () => {
|
||||
let linker: AutoLinker;
|
||||
let mockVault: ReturnType<typeof createMockVault>;
|
||||
let mockIndexer: { searchVault: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockVault = createMockVault();
|
||||
mockIndexer = {
|
||||
searchVault: jest.fn(),
|
||||
};
|
||||
|
||||
linker = new AutoLinker(mockVault as any, mockIndexer as any, {
|
||||
enabled: true,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRelatedNotes', () => {
|
||||
it('should return related notes excluding self', async () => {
|
||||
const file = { path: 'note.md', basename: 'Note' } as any;
|
||||
mockVault.read.mockResolvedValue('Content about AI');
|
||||
mockIndexer.searchVault!.mockResolvedValue([
|
||||
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
|
||||
{ path: 'note.md', title: 'Note', score: 0.95, content: '' },
|
||||
]);
|
||||
|
||||
const result = await linker.findRelatedNotes(file);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('other.md');
|
||||
});
|
||||
|
||||
it('should filter by similarity threshold', async () => {
|
||||
const file = { path: 'note.md', basename: 'Note' } as any;
|
||||
mockVault.read.mockResolvedValue('Content');
|
||||
mockIndexer.searchVault!.mockResolvedValue([
|
||||
{ path: 'high.md', title: 'High', score: 0.8, content: '' },
|
||||
{ path: 'low.md', title: 'Low', score: 0.3, content: '' },
|
||||
]);
|
||||
|
||||
const result = await linker.findRelatedNotes(file);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('high.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRelatedLinks', () => {
|
||||
it('should add Related Notes section', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockVault.read.mockResolvedValue('# Note\n\nContent');
|
||||
|
||||
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
|
||||
|
||||
expect(mockVault.modify).toHaveBeenCalledWith(
|
||||
file,
|
||||
expect.stringContaining('## Related Notes')
|
||||
);
|
||||
expect(mockVault.modify).toHaveBeenCalledWith(
|
||||
file,
|
||||
expect.stringContaining('[[Other|other]]')
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip if Related Notes already exists', async () => {
|
||||
const file = { path: 'note.md' } as any;
|
||||
mockVault.read.mockResolvedValue('# Note\n\n## Related Notes\nAlready linked');
|
||||
|
||||
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
|
||||
expect(mockVault.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should return early when disabled', async () => {
|
||||
linker.updateConfig({ enabled: false, maxLinksPerNote: 3, similarityThreshold: 0.5 });
|
||||
const result = await linker.run();
|
||||
expect(result.linked).toBe(0);
|
||||
});
|
||||
|
||||
it('should respect targetFolder', async () => {
|
||||
linker.setTargetFolder('Projects');
|
||||
const files = [{ path: 'Projects/note1.md' }, { path: 'Archive/note2.md' }] as any[];
|
||||
mockVault.getMarkdownFiles.mockReturnValue(files);
|
||||
mockVault.read.mockResolvedValue('Content');
|
||||
mockIndexer.searchVault!.mockResolvedValue([]);
|
||||
|
||||
const result = await linker.run();
|
||||
expect(mockVault.read).toHaveBeenCalledTimes(1);
|
||||
expect(mockVault.read).toHaveBeenCalledWith(files[0]);
|
||||
});
|
||||
|
||||
it('should update targetFolder through updateConfig', async () => {
|
||||
linker.setTargetFolder('Projects');
|
||||
linker.updateConfig({
|
||||
enabled: true,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.5,
|
||||
targetFolder: 'Archive',
|
||||
});
|
||||
const files = [{ path: 'Projects/note1.md' }, { path: 'Archive/note2.md' }] as any[];
|
||||
mockVault.getMarkdownFiles.mockReturnValue(files);
|
||||
mockVault.read.mockResolvedValue('Content');
|
||||
mockIndexer.searchVault!.mockResolvedValue([]);
|
||||
|
||||
await linker.run();
|
||||
|
||||
expect(mockVault.read).toHaveBeenCalledTimes(1);
|
||||
expect(mockVault.read).toHaveBeenCalledWith(files[1]);
|
||||
});
|
||||
|
||||
it('should return dry-run proposals when dryRun is true', async () => {
|
||||
const files = [{ path: 'note.md' }] as any[];
|
||||
mockVault.getMarkdownFiles.mockReturnValue(files);
|
||||
mockVault.read.mockResolvedValue('Content');
|
||||
mockIndexer.searchVault!.mockResolvedValue([
|
||||
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
|
||||
]);
|
||||
|
||||
const result = await linker.run(true);
|
||||
expect(result.dryRun).toBeDefined();
|
||||
expect(result.dryRun!.length).toBe(1);
|
||||
expect(result.dryRun![0].relatedNotes[0].path).toBe('other.md');
|
||||
expect(mockVault.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+363
-15
@@ -1,12 +1,18 @@
|
||||
import { ChatView } from '../src/chat-view';
|
||||
import { PluginSettings, OllamaMessage, ChatMessage, OllamaTool, ToolCall } from '../src/types';
|
||||
import { ErrorHandler } from '../src/error-handler';
|
||||
import { ChatHistoryManager } from '../src/chat-history';
|
||||
|
||||
// Mock Obsidian types
|
||||
interface MockVault {
|
||||
getMarkdownFiles: () => any[];
|
||||
read: () => Promise<string>;
|
||||
cachedRead: () => Promise<string>;
|
||||
getAbstractFileByPath: () => any;
|
||||
create: () => Promise<any>;
|
||||
modify: () => Promise<void>;
|
||||
rename: () => Promise<void>;
|
||||
delete: () => Promise<void>;
|
||||
}
|
||||
interface MockWorkspace {
|
||||
getLeaf: () => any;
|
||||
@@ -28,10 +34,14 @@ jest.mock('obsidian', () => ({
|
||||
|
||||
const mockSettings: PluginSettings = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
chatModel: 'llama3',
|
||||
agentModel: 'llama3',
|
||||
model: 'llama3',
|
||||
vaultSearchLimit: 3,
|
||||
maxMessageHistory: 50,
|
||||
maxContextLength: 8000,
|
||||
lastIndexTime: 0,
|
||||
agentMode: 'ask',
|
||||
cacheConfig: {
|
||||
enabled: false,
|
||||
similarityThreshold: 0.9,
|
||||
@@ -39,6 +49,40 @@ const mockSettings: PluginSettings = {
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
vaultIndexConfig: {
|
||||
enabled: false,
|
||||
similarityThreshold: 0.75,
|
||||
collectionName: 'test-vault-index',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
},
|
||||
autoTagConfig: {
|
||||
enabled: false,
|
||||
maxTagsPerNote: 5,
|
||||
minNoteLength: 50,
|
||||
maxNoteLength: 8000,
|
||||
tagPromptTemplate: 'Tags: {{content}}',
|
||||
dryRun: false,
|
||||
targetFolder: '',
|
||||
normalizeTags: true,
|
||||
},
|
||||
autoLinkConfig: {
|
||||
enabled: false,
|
||||
maxLinksPerNote: 3,
|
||||
similarityThreshold: 0.6,
|
||||
targetFolder: '',
|
||||
dryRun: false,
|
||||
},
|
||||
structuredMemoryConfig: {
|
||||
enabled: true,
|
||||
maxSummaries: 10,
|
||||
maxPreferences: 20,
|
||||
maxFacts: 50,
|
||||
},
|
||||
toolTelemetryConfig: {
|
||||
enabled: true,
|
||||
maxEntries: 100,
|
||||
},
|
||||
};
|
||||
|
||||
describe('ChatView', () => {
|
||||
@@ -47,11 +91,17 @@ describe('ChatView', () => {
|
||||
let mockApp: MockApp;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockApp = {
|
||||
vault: {
|
||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||
read: jest.fn().mockResolvedValue(''),
|
||||
cachedRead: jest.fn().mockResolvedValue(''),
|
||||
getAbstractFileByPath: jest.fn().mockReturnValue(null),
|
||||
create: jest.fn().mockResolvedValue(null),
|
||||
modify: jest.fn().mockResolvedValue(undefined),
|
||||
rename: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
workspace: {
|
||||
getLeaf: jest.fn(),
|
||||
@@ -65,7 +115,15 @@ describe('ChatView', () => {
|
||||
app: mockApp,
|
||||
};
|
||||
|
||||
view = new ChatView(mockLeaf as unknown as any, mockSettings);
|
||||
view = new ChatView(
|
||||
mockLeaf as unknown as any,
|
||||
mockSettings,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
new ChatHistoryManager(),
|
||||
undefined
|
||||
);
|
||||
// Obsidian's contentEl has a createEl helper that standard DOM lacks
|
||||
// Unlike standard DOM, Obsidian elements can create nested elements with createEl
|
||||
const contentDiv = document.createElement('div') as any;
|
||||
@@ -86,6 +144,18 @@ describe('ChatView', () => {
|
||||
|
||||
contentDiv.createEl = createElementWithCreateEl(contentDiv);
|
||||
view.contentEl = contentDiv;
|
||||
|
||||
// Mock NoteContextBuilder to avoid needing full Obsidian API mocks
|
||||
jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
|
||||
explicitMentions: [],
|
||||
openNote: undefined,
|
||||
selectedText: undefined,
|
||||
backlinks: [],
|
||||
outlinks: [],
|
||||
relatedNotes: [],
|
||||
searchResults: [],
|
||||
});
|
||||
jest.spyOn(view['noteContextBuilder'], 'formatContext').mockReturnValue('');
|
||||
});
|
||||
|
||||
describe('getViewType', () => {
|
||||
@@ -100,6 +170,12 @@ describe('ChatView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIcon', () => {
|
||||
it('should return the bot icon', () => {
|
||||
expect(view.getIcon()).toBe('bot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onOpen', () => {
|
||||
it('should call render and setup event listeners', async () => {
|
||||
const renderSpy = jest.spyOn(view, 'render');
|
||||
@@ -158,6 +234,18 @@ describe('ChatView', () => {
|
||||
const messages = view.contentEl.querySelectorAll('.ollama-message');
|
||||
expect(messages.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should update the system prompt when mode selector changes', async () => {
|
||||
await view.render();
|
||||
const selector = view.contentEl.querySelector('.ollama-mode-selector') as HTMLSelectElement;
|
||||
selector.appendChild(new Option('Edit', 'edit'));
|
||||
selector.value = 'edit';
|
||||
selector.dispatchEvent(new Event('change'));
|
||||
|
||||
const systemPrompt = view['conversationStateManager'].getLongTermContext()[0].content;
|
||||
expect(view.getAgentMode()).toBe('edit');
|
||||
expect(systemPrompt).toContain('helps edit and manage notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleUserInput', () => {
|
||||
@@ -204,6 +292,41 @@ describe('ChatView', () => {
|
||||
expect(lastMessage.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should finalize with a visible fallback if tool processing returns no output', async () => {
|
||||
view.setAgentMode('research');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'find project notes';
|
||||
|
||||
jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: '{"query":"project notes"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
})()
|
||||
);
|
||||
jest.spyOn(view as any, 'processToolCalls').mockResolvedValue(undefined);
|
||||
|
||||
await (view as any).handleUserInput('find project notes');
|
||||
|
||||
const messages = (view as any).messages;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(lastMessage.isStreaming).toBe(false);
|
||||
expect(lastMessage.isThinking).toBe(false);
|
||||
expect(lastMessage.content).toBe('No response was returned.');
|
||||
});
|
||||
|
||||
it('should handle streaming re-attach when existing streaming element is found', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
@@ -294,12 +417,20 @@ describe('ChatView', () => {
|
||||
expect((view as any).messages.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should call vaultIndexer.searchVault with user input', async () => {
|
||||
it('should call noteContextBuilder with user input', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
|
||||
|
||||
const searchSpy = jest.spyOn(view['vaultIndexer'], 'searchVault').mockResolvedValue([]);
|
||||
const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
|
||||
explicitMentions: [],
|
||||
openNote: undefined,
|
||||
selectedText: undefined,
|
||||
backlinks: [],
|
||||
outlinks: [],
|
||||
relatedNotes: [],
|
||||
searchResults: [],
|
||||
});
|
||||
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
||||
(async function* () {
|
||||
yield { role: 'assistant', content: 'response' };
|
||||
@@ -308,10 +439,67 @@ describe('ChatView', () => {
|
||||
|
||||
await (view as any).handleUserInput('search query');
|
||||
|
||||
expect(searchSpy).toHaveBeenCalledWith('search query', 3); // Should use DEFAULT_VAULT_SEARCH_LIMIT
|
||||
expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
|
||||
expect(chatSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should auto-run read tools when organize mode returns a non-action response', async () => {
|
||||
view.setAgentMode('organize');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value =
|
||||
'please implement the suggested structure by creating folders and moving notes';
|
||||
|
||||
const streamSpy = jest.spyOn(view['ollamaClient'], 'streamChat');
|
||||
streamSpy
|
||||
.mockReturnValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Let me look at the current note and previous conversation to understand what structure was suggested.',
|
||||
};
|
||||
})()
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Let me read the current Prompts note and look at the Vault Index for more context.',
|
||||
};
|
||||
})()
|
||||
);
|
||||
|
||||
const handleToolSpy = jest.spyOn(view['toolExecutor'], 'handleToolCall').mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Found vault context',
|
||||
data: [{ path: 'Prompts.md', title: 'Prompts' }],
|
||||
});
|
||||
jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({
|
||||
role: 'assistant',
|
||||
content: 'I found vault context and can now suggest the next organization step.',
|
||||
});
|
||||
|
||||
await (view as any).handleUserInput(
|
||||
'please implement the suggested structure by creating folders and moving notes'
|
||||
);
|
||||
|
||||
expect(handleToolSpy).toHaveBeenCalled();
|
||||
expect(
|
||||
handleToolSpy.mock.calls.some(([toolCall]) => toolCall.function.name === 'get_vault_stats')
|
||||
).toBe(true);
|
||||
expect(
|
||||
handleToolSpy.mock.calls.some(([toolCall]) => toolCall.function.name === 'list_vault_tags')
|
||||
).toBe(true);
|
||||
|
||||
const messages = (view as any).messages;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(lastMessage.content).toBe(
|
||||
'I found vault context and can now suggest the next organization step.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors during user input gracefully', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
@@ -362,7 +550,8 @@ describe('ChatView', () => {
|
||||
expect(lastMessage.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should process tool calls with follow-up context', async () => {
|
||||
it('should execute write tools immediately with CoW undo', async () => {
|
||||
view.setAgentMode('edit');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
||||
@@ -386,14 +575,134 @@ describe('ChatView', () => {
|
||||
);
|
||||
const followUpSpy = jest
|
||||
.spyOn(view['ollamaClient'], 'chat')
|
||||
.mockResolvedValue({ role: 'assistant', content: ' follow-up' });
|
||||
.mockResolvedValue({ role: 'assistant', content: 'follow-up' });
|
||||
|
||||
// Mock handleToolCall so the write executes without needing real vault
|
||||
jest.spyOn(view['toolExecutor'], 'handleToolCall').mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Note created successfully',
|
||||
});
|
||||
|
||||
await (view as any).handleUserInput('test');
|
||||
expect(chatSpy).toHaveBeenCalled();
|
||||
// Write tools execute immediately — follow-up is called right away
|
||||
expect(followUpSpy).toHaveBeenCalled();
|
||||
// Verify that tool calls resulted in follow-up messages
|
||||
// No pending actions queue in CoW mode
|
||||
expect((view as any).pendingActions.length).toBe(0);
|
||||
expect((view as any).messages.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should apply pending actions and trigger follow-up', async () => {
|
||||
view.setAgentMode('edit');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
||||
const assistantMessageId = 'test-assistant-id';
|
||||
view['messages'] = [
|
||||
{
|
||||
id: 'user-id',
|
||||
role: 'user',
|
||||
content: 'test',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: 'test',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: false,
|
||||
},
|
||||
];
|
||||
|
||||
view['pendingActions'] = [
|
||||
{
|
||||
id: 'tool_1',
|
||||
toolCall: {
|
||||
id: 'tool_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: '{"path":"test/file.md","content":"Test content"}',
|
||||
},
|
||||
} as unknown as any,
|
||||
operation: 'create',
|
||||
path: 'test/file.md',
|
||||
description: 'Create note: test/file.md',
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
view['pendingReadResults'] = [];
|
||||
view['pendingFollowUpContext'] = {
|
||||
messages: [],
|
||||
tools: [],
|
||||
assistantMessageId,
|
||||
allToolCalls: [],
|
||||
assistantText: 'test',
|
||||
};
|
||||
|
||||
const followUpSpy = jest
|
||||
.spyOn(view['ollamaClient'], 'chat')
|
||||
.mockResolvedValue({ role: 'assistant', content: ' follow-up' });
|
||||
|
||||
const toolExecutor = view['toolExecutor'];
|
||||
jest.spyOn(toolExecutor, 'handleToolCall').mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Note created successfully',
|
||||
});
|
||||
|
||||
await view.applyPendingActions();
|
||||
expect(followUpSpy).toHaveBeenCalled();
|
||||
expect((view as any).pendingActions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should cancel pending actions', async () => {
|
||||
view.setAgentMode('edit');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
||||
const assistantMessageId = 'test-assistant-id';
|
||||
view['messages'] = [
|
||||
{
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: 'Proposed actions...',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: false,
|
||||
},
|
||||
];
|
||||
view['pendingActions'] = [
|
||||
{
|
||||
id: 'tool_1',
|
||||
toolCall: {
|
||||
id: 'tool_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: '{"path":"test.md","content":"test"}',
|
||||
},
|
||||
} as unknown as any,
|
||||
operation: 'create',
|
||||
path: 'test.md',
|
||||
description: 'Create note: test.md',
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
view['pendingFollowUpContext'] = {
|
||||
messages: [],
|
||||
tools: [],
|
||||
assistantMessageId,
|
||||
allToolCalls: [],
|
||||
assistantText: 'Proposed actions...',
|
||||
};
|
||||
|
||||
view.cancelPendingActions();
|
||||
const msg = (view as any).messages.find((m: any) => m.id === assistantMessageId);
|
||||
expect(msg.content).toContain('cancelled');
|
||||
expect((view as any).pendingActions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle tool call errors gracefully and continue with partial results', async () => {
|
||||
view.setAgentMode('edit');
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
||||
@@ -423,14 +732,13 @@ describe('ChatView', () => {
|
||||
tool_calls: [],
|
||||
});
|
||||
|
||||
// Mock tool executor to return mixed results
|
||||
// Mock tool executor: read tool fails, write tool succeeds
|
||||
const toolExecutor = view['toolExecutor'];
|
||||
jest.spyOn(toolExecutor, 'handleToolCall').mockImplementation(async (call) => {
|
||||
if (call.function.name === 'create_file') {
|
||||
return { success: true, message: 'File created successfully' };
|
||||
} else {
|
||||
if (call.function.name === 'nonexistent_tool') {
|
||||
throw new Error('Tool not found');
|
||||
}
|
||||
return { success: true, message: 'Done' };
|
||||
});
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
@@ -439,7 +747,8 @@ describe('ChatView', () => {
|
||||
await (view as any).handleUserInput('test');
|
||||
|
||||
expect(chatSpy).toHaveBeenCalled();
|
||||
expect(followUpSpy).toHaveBeenCalled(); // Should still call follow-up with partial results
|
||||
// Write tools execute immediately with CoW; follow-up is called once results are ready
|
||||
expect(followUpSpy).toHaveBeenCalled();
|
||||
expect(errorHandlerSpy).toHaveBeenCalledWith(expect.any(Error), 'ChatView.handleUserInput');
|
||||
expect((view as any).messages.length).toBeGreaterThan(1);
|
||||
consoleSpy.mockRestore();
|
||||
@@ -534,6 +843,37 @@ describe('ChatView', () => {
|
||||
errorHandlerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle /workflow command', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = '/workflow find notes and summarize';
|
||||
|
||||
const workflowSpy = jest
|
||||
.spyOn(view['workflowEngine'], 'executeWorkflowFromQuery')
|
||||
.mockResolvedValue({
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'Test Workflow',
|
||||
success: true,
|
||||
stepResults: [
|
||||
{
|
||||
stepId: 's1',
|
||||
stepName: 'Search',
|
||||
success: true,
|
||||
data: ['note1'],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
finalOutput: 'Summary result',
|
||||
});
|
||||
|
||||
await (view as any).handleUserInput('/workflow find notes and summarize');
|
||||
|
||||
expect(workflowSpy).toHaveBeenCalledWith('find notes and summarize', expect.any(Array));
|
||||
const assistantMsg = (view as any).messages.find((m: any) => m.role === 'assistant');
|
||||
expect(assistantMsg.content).toContain('Test Workflow');
|
||||
expect(assistantMsg.content).toContain('Summary result');
|
||||
});
|
||||
|
||||
it('should limit conversation history to maxMessageHistory', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
@@ -562,12 +902,20 @@ describe('ChatView', () => {
|
||||
expect((view as any).messages.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should call vaultIndexer.searchVault with user input', async () => {
|
||||
it('should call noteContextBuilder with user input', async () => {
|
||||
view['sendButton'] = document.createElement('button');
|
||||
view['inputEl'] = document.createElement('textarea');
|
||||
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
|
||||
|
||||
const searchSpy = jest.spyOn(view['vaultIndexer'], 'searchVault').mockResolvedValue([]);
|
||||
const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
|
||||
explicitMentions: [],
|
||||
openNote: undefined,
|
||||
selectedText: undefined,
|
||||
backlinks: [],
|
||||
outlinks: [],
|
||||
relatedNotes: [],
|
||||
searchResults: [],
|
||||
});
|
||||
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
||||
(async function* () {
|
||||
yield { role: 'assistant', content: 'response' };
|
||||
@@ -576,7 +924,7 @@ describe('ChatView', () => {
|
||||
|
||||
await (view as any).handleUserInput('search query');
|
||||
|
||||
expect(searchSpy).toHaveBeenCalledWith('search query', 3); // Should use DEFAULT_VAULT_SEARCH_LIMIT
|
||||
expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
|
||||
expect(chatSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@ describe('ConversationStateManager', () => {
|
||||
let manager: ConversationStateManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ConversationStateManager();
|
||||
manager = new ConversationStateManager(
|
||||
'You are an assistant that can help answer questions using the contents of a vault'
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with default system message in long-term context', () => {
|
||||
const longTerm = manager.getLongTermContext();
|
||||
const customManager = new ConversationStateManager();
|
||||
const longTerm = customManager.getLongTermContext();
|
||||
expect(longTerm).toHaveLength(1);
|
||||
expect(longTerm[0].role).toBe('system');
|
||||
expect(longTerm[0].content).toContain(
|
||||
'You are an assistant that can help answer questions using the contents of a vault'
|
||||
);
|
||||
expect(longTerm[0].content).toContain('immediately emit the tool_call');
|
||||
});
|
||||
|
||||
it('should initialize with empty short-term and medium-term contexts', () => {
|
||||
@@ -64,25 +65,16 @@ describe('ConversationStateManager', () => {
|
||||
it('should replace default system message with custom persona', () => {
|
||||
manager.setPersona('You are a coding expert.');
|
||||
const longTerm = manager.getLongTermContext();
|
||||
expect(longTerm.some((msg) => msg.content === 'You are a coding expert.')).toBe(true);
|
||||
expect(
|
||||
longTerm.some((msg) =>
|
||||
msg.content.includes(
|
||||
'You are an assistant that can help answer questions using the contents of a vault'
|
||||
)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(longTerm).toHaveLength(1);
|
||||
expect(longTerm[0].content).toBe('You are a coding expert.');
|
||||
});
|
||||
|
||||
it('should allow multiple persona updates', () => {
|
||||
manager.setPersona('First persona.');
|
||||
manager.setPersona('Second persona.');
|
||||
const longTerm = manager.getLongTermContext();
|
||||
expect(longTerm.some((msg) => msg.content === 'Second persona.')).toBe(true);
|
||||
// The actual implementation filters out the default system message but keeps previous persona messages
|
||||
// So we should expect to find both personas in the long-term context
|
||||
expect(longTerm.some((msg) => msg.content === 'First persona.')).toBe(true);
|
||||
expect(longTerm).toHaveLength(2);
|
||||
expect(longTerm).toHaveLength(1);
|
||||
expect(longTerm[0].content).toBe('Second persona.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,7 +100,7 @@ describe('ConversationStateManager', () => {
|
||||
|
||||
// Long-term comes first
|
||||
expect(messages[0].role).toBe('system');
|
||||
expect(messages[0].content).toContain('You are an assistant');
|
||||
expect(messages[0].content).toContain('contents of a vault');
|
||||
|
||||
// Medium-term follows
|
||||
expect(messages[1].content).toBe('Medium');
|
||||
@@ -136,9 +128,14 @@ describe('ConversationStateManager', () => {
|
||||
manager.setPersona('Custom persona');
|
||||
manager.clear();
|
||||
const longTerm = manager.getLongTermContext();
|
||||
expect(longTerm[0].content).toContain(
|
||||
'You are an assistant that can help answer questions using the contents of a vault'
|
||||
);
|
||||
expect(longTerm[0].content).toContain('immediately emit the tool_call');
|
||||
});
|
||||
|
||||
it('should accept a custom system prompt when clearing', () => {
|
||||
manager.setPersona('Custom persona');
|
||||
manager.clear('Custom system prompt');
|
||||
const longTerm = manager.getLongTermContext();
|
||||
expect(longTerm[0].content).toBe('Custom system prompt');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +206,7 @@ describe('ConversationStateManager', () => {
|
||||
const longTerm = manager.getLongTermContext();
|
||||
expect(longTerm).toHaveLength(1);
|
||||
expect(longTerm[0].role).toBe('system');
|
||||
expect(longTerm[0].content).toContain('immediately emit the tool_call');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ Content`;
|
||||
firstParagraph: 'First paragraph',
|
||||
wordCount: 2,
|
||||
chunkIndex: 0,
|
||||
chunkSize: 100
|
||||
chunkSize: 100,
|
||||
};
|
||||
|
||||
const prompt = (vectorizer as any).createPrompt(mockChunk);
|
||||
@@ -215,7 +215,7 @@ Content`;
|
||||
expect(prompt).toContain('Test');
|
||||
expect(prompt).toContain('First paragraph');
|
||||
expect(prompt).toContain('Heading');
|
||||
expect(prompt).toContain('tags');
|
||||
// Frontmatter is no longer included in embedding prompts
|
||||
});
|
||||
|
||||
// Note: Actual embedding tests would require mocking fetch or integration testing
|
||||
@@ -233,7 +233,7 @@ Content`;
|
||||
firstParagraph: 'First paragraph',
|
||||
wordCount: 2,
|
||||
chunkIndex: 0,
|
||||
chunkSize: 100
|
||||
chunkSize: 100,
|
||||
};
|
||||
|
||||
// Mock fetch to simulate an error
|
||||
@@ -290,12 +290,12 @@ This is a test document for pipeline processing.`;
|
||||
it('should process files in batches', async () => {
|
||||
const files: MockVaultFile[] = [
|
||||
{ basename: 'file1', path: 'file1.md' },
|
||||
{ basename: 'file2', path: 'file2.md' }
|
||||
{ basename: 'file2', path: 'file2.md' },
|
||||
];
|
||||
|
||||
const fileContents = {
|
||||
'file1.md': '# File 1\n\nContent 1',
|
||||
'file2.md': '# File 2\n\nContent 2'
|
||||
'file2.md': '# File 2\n\nContent 2',
|
||||
};
|
||||
|
||||
const results = await pipeline.processFilesInBatches(files, fileContents, 1);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { NoteContextBuilder } from '../src/note-context-builder';
|
||||
import { VaultIndexer } from '../src/vault-indexer';
|
||||
import { TFile } from 'obsidian';
|
||||
|
||||
describe('NoteContextBuilder', () => {
|
||||
let builder: NoteContextBuilder;
|
||||
let mockVault: any;
|
||||
let mockApp: any;
|
||||
let mockVaultIndexer: jest.Mocked<VaultIndexer>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = {
|
||||
getAbstractFileByPath: jest.fn(),
|
||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||
cachedRead: jest.fn().mockResolvedValue(''),
|
||||
};
|
||||
|
||||
mockApp = {
|
||||
workspace: {
|
||||
getActiveFile: jest.fn().mockReturnValue(null),
|
||||
getActiveViewOfType: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
metadataCache: {
|
||||
getCache: jest.fn().mockReturnValue(null),
|
||||
getFileCache: jest.fn().mockReturnValue(null),
|
||||
resolvedLinks: {},
|
||||
},
|
||||
};
|
||||
|
||||
mockVaultIndexer = {
|
||||
searchVault: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as jest.Mocked<VaultIndexer>;
|
||||
|
||||
builder = new NoteContextBuilder(mockVault, mockApp, mockVaultIndexer);
|
||||
});
|
||||
|
||||
describe('extractExplicitMentions', () => {
|
||||
it('should extract simple wikilinks', () => {
|
||||
const result = builder.extractExplicitMentions('What about [[My Note]]?');
|
||||
expect(result).toEqual(['My Note']);
|
||||
});
|
||||
|
||||
it('should extract multiple wikilinks', () => {
|
||||
const result = builder.extractExplicitMentions('See [[Note A]] and [[Note B]]');
|
||||
expect(result).toEqual(['Note A', 'Note B']);
|
||||
});
|
||||
|
||||
it('should strip aliases', () => {
|
||||
const result = builder.extractExplicitMentions('[[Real Name|Display Name]]');
|
||||
expect(result).toEqual(['Real Name']);
|
||||
});
|
||||
|
||||
it('should deduplicate mentions', () => {
|
||||
const result = builder.extractExplicitMentions('[[Note]] [[Note]]');
|
||||
expect(result).toEqual(['Note']);
|
||||
});
|
||||
|
||||
it('should return empty array when no wikilinks', () => {
|
||||
const result = builder.extractExplicitMentions('Just plain text');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectScopeIntent', () => {
|
||||
it('should detect explicit scope', () => {
|
||||
expect(builder.detectScopeIntent('use only this note')).toBe('explicit');
|
||||
expect(builder.detectScopeIntent('Just this note please')).toBe('explicit');
|
||||
});
|
||||
|
||||
it('should detect related scope', () => {
|
||||
expect(builder.detectScopeIntent('include related notes')).toBe('related');
|
||||
expect(builder.detectScopeIntent('show me linked notes')).toBe('related');
|
||||
});
|
||||
|
||||
it('should default to default', () => {
|
||||
expect(builder.detectScopeIntent('hello world')).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildContext', () => {
|
||||
it('should include explicit mentions', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue({ path: 'Note.md', basename: 'Note' });
|
||||
mockVault.getMarkdownFiles.mockReturnValue([{ path: 'Note.md', basename: 'Note' }]);
|
||||
mockVault.cachedRead.mockResolvedValue('# Note\nContent');
|
||||
|
||||
const ctx = await builder.buildContext('What about [[Note]]?', 5);
|
||||
expect(ctx.explicitMentions.length).toBe(1);
|
||||
expect(ctx.explicitMentions[0].title).toBe('Note');
|
||||
});
|
||||
|
||||
it('should include open note when available', async () => {
|
||||
const activeFile = { path: 'Open.md', basename: 'Open' };
|
||||
mockApp.workspace.getActiveFile.mockReturnValue(activeFile);
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(activeFile);
|
||||
mockVault.cachedRead.mockResolvedValue('# Open\nBody');
|
||||
|
||||
const ctx = await builder.buildContext('hello', 5);
|
||||
expect(ctx.openNote).toBeDefined();
|
||||
expect(ctx.openNote?.title).toBe('Open');
|
||||
});
|
||||
|
||||
it('should include selected text when available', async () => {
|
||||
const activeFile = { path: 'Open.md', basename: 'Open' };
|
||||
mockApp.workspace.getActiveFile.mockReturnValue(activeFile);
|
||||
mockVault.getAbstractFileByPath.mockReturnValue(activeFile);
|
||||
mockVault.cachedRead.mockResolvedValue('Body');
|
||||
|
||||
const mockEditor = { getSelection: jest.fn().mockReturnValue('Selected passage') };
|
||||
const mockView = { editor: mockEditor };
|
||||
mockApp.workspace.getActiveViewOfType.mockReturnValue(mockView);
|
||||
|
||||
const ctx = await builder.buildContext('hello', 5);
|
||||
expect(ctx.selectedText).toBe('Selected passage');
|
||||
});
|
||||
|
||||
it('should call vaultIndexer.searchVault for default scope', async () => {
|
||||
await builder.buildContext('search term', 5);
|
||||
expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('search term', 5);
|
||||
});
|
||||
|
||||
it('should skip vault search for explicit scope', async () => {
|
||||
mockVault.getAbstractFileByPath.mockReturnValue({ path: 'N.md', basename: 'N' });
|
||||
mockVault.getMarkdownFiles.mockReturnValue([{ path: 'N.md', basename: 'N' }]);
|
||||
mockVault.cachedRead.mockResolvedValue('');
|
||||
|
||||
await builder.buildContext('use only this note [[N]]', 5);
|
||||
expect(mockVaultIndexer.searchVault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should gather backlinks and outlinks in related mode', async () => {
|
||||
const activeFile = { path: 'A.md', basename: 'A' };
|
||||
const backFile = { path: 'B.md', basename: 'B' };
|
||||
const outFile = { path: 'C.md', basename: 'C' };
|
||||
|
||||
mockApp.workspace.getActiveFile.mockReturnValue(activeFile);
|
||||
mockApp.metadataCache.resolvedLinks = { 'B.md': { 'A.md': 1 } };
|
||||
mockApp.metadataCache.getCache.mockReturnValue({ links: [{ link: 'C' }] });
|
||||
|
||||
mockVault.getAbstractFileByPath.mockImplementation((p: string) => {
|
||||
if (p === 'A.md') return activeFile;
|
||||
if (p === 'B.md') return backFile;
|
||||
if (p === 'C.md') return outFile;
|
||||
return null;
|
||||
});
|
||||
mockVault.getMarkdownFiles.mockReturnValue([backFile, outFile]);
|
||||
mockVault.cachedRead.mockResolvedValue('');
|
||||
|
||||
const ctx = await builder.buildContext('include related notes', 5);
|
||||
expect(ctx.backlinks.length).toBe(1);
|
||||
expect(ctx.backlinks[0].path).toBe('B.md');
|
||||
expect(ctx.outlinks.length).toBe(1);
|
||||
expect(ctx.outlinks[0].path).toBe('C.md');
|
||||
expect(ctx.relatedNotes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatContext', () => {
|
||||
it('should include selected text, open note, mentions, and search results', () => {
|
||||
const ctx = {
|
||||
explicitMentions: [
|
||||
{ path: 'M.md', title: 'Mention', content: 'Mention body', score: 1 },
|
||||
],
|
||||
openNote: { path: 'O.md', title: 'Open', content: 'Open body', score: 1 },
|
||||
selectedText: 'Selected text',
|
||||
backlinks: [],
|
||||
outlinks: [],
|
||||
relatedNotes: [],
|
||||
searchResults: [
|
||||
{ path: 'S.md', title: 'Search', content: 'Search body', score: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const formatted = builder.formatContext(ctx as any, 2000);
|
||||
expect(formatted).toContain('Selected text from current note:');
|
||||
expect(formatted).toContain('Selected text');
|
||||
expect(formatted).toContain('Current open note: Open (O.md)');
|
||||
expect(formatted).toContain('Explicitly mentioned notes:');
|
||||
expect(formatted).toContain('Mention (M.md)');
|
||||
expect(formatted).toContain('Vault search results:');
|
||||
expect(formatted).toContain('Search (S.md)');
|
||||
});
|
||||
|
||||
it('should truncate long context', () => {
|
||||
const ctx = {
|
||||
explicitMentions: [],
|
||||
openNote: undefined,
|
||||
selectedText: undefined,
|
||||
backlinks: [],
|
||||
outlinks: [],
|
||||
relatedNotes: [],
|
||||
searchResults: [
|
||||
{ path: 'S.md', title: 'Search', content: 'A'.repeat(500), score: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const formatted = builder.formatContext(ctx as any, 50);
|
||||
expect(formatted).toContain('... [truncated]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,28 +32,30 @@ describe('OllamaClient', () => {
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize cache service when enabled', () => {
|
||||
it('should create cache service when enabled but not initialize it eagerly', () => {
|
||||
const cacheConfig: CacheConfig = {
|
||||
enabled: true,
|
||||
similarityThreshold: 0.85,
|
||||
collectionName: 'test_cache',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000'
|
||||
chromaURL: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
const client = new OllamaClient(mockBaseUrl, mockModel, undefined, cacheConfig);
|
||||
|
||||
expect(client).toBeInstanceOf(OllamaClient);
|
||||
expect(mockInitialize).toHaveBeenCalledTimes(1);
|
||||
// Eager initialization was removed to avoid unhandled rejections;
|
||||
// initialization now happens via initializeCache() only.
|
||||
expect(mockInitialize).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should not initialize cache service when disabled', () => {
|
||||
it('should not create cache service when disabled', () => {
|
||||
const cacheConfig: CacheConfig = {
|
||||
enabled: false,
|
||||
similarityThreshold: 0.85,
|
||||
collectionName: 'test_cache',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000'
|
||||
chromaURL: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
const client = new OllamaClient(mockBaseUrl, mockModel, undefined, cacheConfig);
|
||||
@@ -70,7 +72,7 @@ describe('OllamaClient', () => {
|
||||
similarityThreshold: 0.85,
|
||||
collectionName: 'test_cache',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000'
|
||||
chromaURL: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
const client = new OllamaClient(mockBaseUrl, mockModel, undefined, cacheConfig);
|
||||
@@ -86,7 +88,7 @@ describe('OllamaClient', () => {
|
||||
similarityThreshold: 0.85,
|
||||
collectionName: 'test_cache',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000'
|
||||
chromaURL: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
const client = new OllamaClient(mockBaseUrl, mockModel, undefined, cacheConfig);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('OllamaClient', () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
await expect(client.chat(mockMessages, mockTools)).rejects.toThrow('Ollama API error: 500');
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should handle missing message content gracefully', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
@@ -214,7 +214,7 @@ describe('OllamaClient', () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(client.streamChatAsPromise(mockMessages, mockTools)).rejects.toThrow(
|
||||
'Ollama API error: 404'
|
||||
'Model "llama3" not found. Run \`ollama pull llama3\` first.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -391,7 +391,7 @@ describe('OllamaClient', () => {
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
expect(chunks.length).toBe(0);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should give up after maxRetries attempts', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
@@ -405,7 +405,7 @@ describe('OllamaClient', () => {
|
||||
}
|
||||
})()
|
||||
).rejects.toThrow('Ollama API error: 500');
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('should not retry on 4xx errors', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
@@ -418,7 +418,7 @@ describe('OllamaClient', () => {
|
||||
/* consume */
|
||||
}
|
||||
})()
|
||||
).rejects.toThrow('Ollama API error: 404');
|
||||
).rejects.toThrow('Model "llama3" not found. Run \`ollama pull llama3\` first.');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ jest.mock('chromadb', () => ({
|
||||
getOrCreateCollection: jest.fn().mockResolvedValue({
|
||||
query: jest.fn(),
|
||||
add: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
}),
|
||||
deleteCollection: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
};
|
||||
}),
|
||||
IncludeEnum: {
|
||||
@@ -117,7 +119,7 @@ describe('SemanticCacheService', () => {
|
||||
mockCollection.query.mockResolvedValue({
|
||||
ids: [['test-id']],
|
||||
documents: [[cachedContent]],
|
||||
distances: [[0.9]], // Above threshold
|
||||
distances: [[0.1]], // Similarity 0.9, above threshold
|
||||
});
|
||||
|
||||
const result = await cacheService.getCache('test query');
|
||||
@@ -125,6 +127,18 @@ describe('SemanticCacheService', () => {
|
||||
expect(result).toBe(cachedContent);
|
||||
expect(mockCollection.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null when cosine distance is too high', async () => {
|
||||
mockCollection.query.mockResolvedValue({
|
||||
ids: [['test-id']],
|
||||
documents: [['unrelated cached response']],
|
||||
distances: [[0.9]], // Similarity 0.1, below threshold
|
||||
});
|
||||
|
||||
const result = await cacheService.getCache('test query');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCache', () => {
|
||||
@@ -135,13 +149,13 @@ describe('SemanticCacheService', () => {
|
||||
|
||||
await disabledCacheService.setCache('test query', 'test response');
|
||||
|
||||
expect(mockCollection.add).not.toHaveBeenCalled();
|
||||
expect(mockCollection.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add content to cache', async () => {
|
||||
await cacheService.setCache('test query', 'test response');
|
||||
|
||||
expect(mockCollection.add).toHaveBeenCalled();
|
||||
expect(mockCollection.upsert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,13 +167,15 @@ describe('SemanticCacheService', () => {
|
||||
|
||||
await disabledCacheService.clearCache();
|
||||
|
||||
expect(mockCollection.reset).not.toHaveBeenCalled();
|
||||
expect(mockChromaClient.deleteCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the cache collection', async () => {
|
||||
it('should clear the cache via deleteCollection', async () => {
|
||||
await cacheService.clearCache();
|
||||
|
||||
expect(mockCollection.reset).toHaveBeenCalled();
|
||||
expect(mockChromaClient.deleteCollection).toHaveBeenCalledWith({
|
||||
name: mockCacheConfig.collectionName,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import {
|
||||
StructuredMemoryManager,
|
||||
createDefaultStructuredMemoryData,
|
||||
} from '../src/structured-memory';
|
||||
import type {
|
||||
StructuredMemoryConfig,
|
||||
ConversationSummary,
|
||||
UserPreference,
|
||||
LearnedFact,
|
||||
OllamaMessage,
|
||||
} from '../src/types';
|
||||
|
||||
describe('createDefaultStructuredMemoryData', () => {
|
||||
it('should return empty arrays for all memory types', () => {
|
||||
const data = createDefaultStructuredMemoryData();
|
||||
expect(data.conversationSummaries).toEqual([]);
|
||||
expect(data.userPreferences).toEqual([]);
|
||||
expect(data.learnedFacts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StructuredMemoryManager', () => {
|
||||
const defaultConfig: StructuredMemoryConfig = {
|
||||
enabled: true,
|
||||
maxSummaries: 3,
|
||||
maxPreferences: 3,
|
||||
maxFacts: 3,
|
||||
};
|
||||
|
||||
let manager: StructuredMemoryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new StructuredMemoryManager(defaultConfig);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with empty data when no initial data provided', () => {
|
||||
expect(manager.getConversationSummaries()).toEqual([]);
|
||||
expect(manager.getUserPreferences()).toEqual([]);
|
||||
expect(manager.getLearnedFacts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with provided data', () => {
|
||||
const initialData = createDefaultStructuredMemoryData();
|
||||
initialData.userPreferences.push({
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
timestamp: Date.now(),
|
||||
source: 'explicit',
|
||||
});
|
||||
const m = new StructuredMemoryManager(defaultConfig, initialData);
|
||||
expect(m.getUserPreferences()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadData and getData', () => {
|
||||
it('should load and return data round-trip', () => {
|
||||
const summary: ConversationSummary = {
|
||||
id: 's1',
|
||||
timestamp: 1000,
|
||||
topic: 'Test',
|
||||
summary: 'A test summary',
|
||||
keyPoints: ['point1'],
|
||||
};
|
||||
manager.addConversationSummary(summary);
|
||||
|
||||
const loaded = manager.getData();
|
||||
expect(loaded.conversationSummaries).toHaveLength(1);
|
||||
|
||||
const newManager = new StructuredMemoryManager(defaultConfig);
|
||||
newManager.loadData(loaded);
|
||||
expect(newManager.getConversationSummaries()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('should enforce new limits after config update', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.addConversationSummary({
|
||||
id: `s${i}`,
|
||||
timestamp: i,
|
||||
topic: `Topic ${i}`,
|
||||
summary: `Summary ${i}`,
|
||||
keyPoints: [`point ${i}`],
|
||||
});
|
||||
}
|
||||
// Already limited to default max of 3
|
||||
expect(manager.getConversationSummaries()).toHaveLength(3);
|
||||
|
||||
manager.updateConfig({ ...defaultConfig, maxSummaries: 2 });
|
||||
expect(manager.getConversationSummaries()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should disable writes when enabled becomes false', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'Test',
|
||||
summary: 'Test',
|
||||
keyPoints: ['test'],
|
||||
});
|
||||
expect(manager.getConversationSummaries()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addConversationSummary', () => {
|
||||
it('should add a summary', () => {
|
||||
const summary: ConversationSummary = {
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'Topic',
|
||||
summary: 'Summary',
|
||||
keyPoints: ['k1'],
|
||||
};
|
||||
manager.addConversationSummary(summary);
|
||||
expect(manager.getConversationSummaries()).toContainEqual(summary);
|
||||
});
|
||||
|
||||
it('should enforce maxSummaries limit keeping newest', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.addConversationSummary({
|
||||
id: `s${i}`,
|
||||
timestamp: i,
|
||||
topic: `Topic ${i}`,
|
||||
summary: `Summary ${i}`,
|
||||
keyPoints: [`point ${i}`],
|
||||
});
|
||||
}
|
||||
const summaries = manager.getConversationSummaries();
|
||||
expect(summaries).toHaveLength(3);
|
||||
expect(summaries[0].id).toBe('s2');
|
||||
expect(summaries[2].id).toBe('s4');
|
||||
});
|
||||
|
||||
it('should not add when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'Topic',
|
||||
summary: 'Summary',
|
||||
keyPoints: ['k1'],
|
||||
});
|
||||
expect(manager.getConversationSummaries()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUserPreference', () => {
|
||||
it('should add a preference', () => {
|
||||
const pref: UserPreference = {
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
timestamp: 1,
|
||||
source: 'explicit',
|
||||
};
|
||||
manager.addUserPreference(pref);
|
||||
expect(manager.getUserPreferences()).toContainEqual(pref);
|
||||
});
|
||||
|
||||
it('should update existing preference by key', () => {
|
||||
manager.addUserPreference({
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
timestamp: 1,
|
||||
source: 'explicit',
|
||||
});
|
||||
manager.addUserPreference({
|
||||
key: 'theme',
|
||||
value: 'light',
|
||||
timestamp: 2,
|
||||
source: 'explicit',
|
||||
});
|
||||
const prefs = manager.getUserPreferences();
|
||||
expect(prefs).toHaveLength(1);
|
||||
expect(prefs[0].value).toBe('light');
|
||||
});
|
||||
|
||||
it('should enforce maxPreferences keeping most recent', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.addUserPreference({
|
||||
key: `pref-${i}`,
|
||||
value: `value-${i}`,
|
||||
timestamp: i,
|
||||
source: 'explicit',
|
||||
});
|
||||
}
|
||||
const prefs = manager.getUserPreferences();
|
||||
expect(prefs).toHaveLength(3);
|
||||
// Most recent 3 (timestamps 2, 3, 4)
|
||||
expect(prefs.map((p) => p.timestamp)).toEqual([4, 3, 2]);
|
||||
});
|
||||
|
||||
it('should not add when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.addUserPreference({
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
timestamp: 1,
|
||||
source: 'explicit',
|
||||
});
|
||||
expect(manager.getUserPreferences()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addLearnedFact', () => {
|
||||
it('should add a fact', () => {
|
||||
const fact: LearnedFact = {
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'The sky is blue.',
|
||||
category: 'general',
|
||||
confidence: 0.9,
|
||||
};
|
||||
manager.addLearnedFact(fact);
|
||||
expect(manager.getLearnedFacts()).toContainEqual(fact);
|
||||
});
|
||||
|
||||
it('should deduplicate facts by content (case-insensitive)', () => {
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'The sky is blue.',
|
||||
category: 'general',
|
||||
confidence: 0.5,
|
||||
});
|
||||
manager.addLearnedFact({
|
||||
id: 'f2',
|
||||
timestamp: 2,
|
||||
content: ' the sky is blue. ',
|
||||
category: 'topic',
|
||||
confidence: 0.8,
|
||||
});
|
||||
const facts = manager.getLearnedFacts();
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0].confidence).toBe(0.8);
|
||||
expect(facts[0].category).toBe('topic');
|
||||
});
|
||||
|
||||
it('should enforce maxFacts keeping highest confidence', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.addLearnedFact({
|
||||
id: `f${i}`,
|
||||
timestamp: i,
|
||||
content: `Fact ${i}`,
|
||||
category: 'general',
|
||||
confidence: 0.1 * i,
|
||||
});
|
||||
}
|
||||
const facts = manager.getLearnedFacts();
|
||||
expect(facts).toHaveLength(3);
|
||||
// Highest confidence facts (0.4, 0.3, 0.2)
|
||||
expect(facts.map((f) => f.confidence)).toEqual([0.4, expect.closeTo(0.3, 10), 0.2]);
|
||||
});
|
||||
|
||||
it('should not add when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'Fact',
|
||||
category: 'general',
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(manager.getLearnedFacts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserPreference', () => {
|
||||
it('should return preference by key', () => {
|
||||
manager.addUserPreference({
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
timestamp: 1,
|
||||
source: 'explicit',
|
||||
});
|
||||
expect(manager.getUserPreference('theme')?.value).toBe('dark');
|
||||
});
|
||||
|
||||
it('should return undefined for missing key', () => {
|
||||
expect(manager.getUserPreference('missing')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLearnedFactsByCategory', () => {
|
||||
it('should filter facts by category', () => {
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'Vault has a /projects folder.',
|
||||
category: 'vault_structure',
|
||||
confidence: 0.8,
|
||||
});
|
||||
manager.addLearnedFact({
|
||||
id: 'f2',
|
||||
timestamp: 2,
|
||||
content: 'User likes markdown.',
|
||||
category: 'general',
|
||||
confidence: 0.7,
|
||||
});
|
||||
expect(manager.getLearnedFactsByCategory('vault_structure')).toHaveLength(1);
|
||||
expect(manager.getLearnedFactsByCategory('general')).toHaveLength(1);
|
||||
expect(manager.getLearnedFactsByCategory('topic')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear methods', () => {
|
||||
it('should clear conversation summaries', () => {
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'T',
|
||||
summary: 'S',
|
||||
keyPoints: ['k'],
|
||||
});
|
||||
manager.clearConversationSummaries();
|
||||
expect(manager.getConversationSummaries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear user preferences', () => {
|
||||
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
|
||||
manager.clearUserPreferences();
|
||||
expect(manager.getUserPreferences()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear learned facts', () => {
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'C',
|
||||
category: 'general',
|
||||
confidence: 0.5,
|
||||
});
|
||||
manager.clearLearnedFacts();
|
||||
expect(manager.getLearnedFacts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear all memory', () => {
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'T',
|
||||
summary: 'S',
|
||||
keyPoints: ['k'],
|
||||
});
|
||||
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'C',
|
||||
category: 'general',
|
||||
confidence: 0.5,
|
||||
});
|
||||
manager.clearAll();
|
||||
expect(manager.getConversationSummaries()).toHaveLength(0);
|
||||
expect(manager.getUserPreferences()).toHaveLength(0);
|
||||
expect(manager.getLearnedFacts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMemoryContext', () => {
|
||||
it('should return empty string when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
expect(manager.buildMemoryContext()).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when no memory exists', () => {
|
||||
expect(manager.buildMemoryContext()).toBe('');
|
||||
});
|
||||
|
||||
it('should include conversation summaries', () => {
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'Test Topic',
|
||||
summary: 'We discussed testing.',
|
||||
keyPoints: ['testing is good'],
|
||||
});
|
||||
const ctx = manager.buildMemoryContext();
|
||||
expect(ctx).toContain('Past Conversations');
|
||||
expect(ctx).toContain('Test Topic');
|
||||
expect(ctx).toContain('We discussed testing.');
|
||||
});
|
||||
|
||||
it('should include user preferences', () => {
|
||||
manager.addUserPreference({
|
||||
key: 'theme',
|
||||
value: 'dark',
|
||||
timestamp: 1,
|
||||
source: 'explicit',
|
||||
});
|
||||
const ctx = manager.buildMemoryContext();
|
||||
expect(ctx).toContain('User Preferences');
|
||||
expect(ctx).toContain('theme: dark');
|
||||
});
|
||||
|
||||
it('should include learned facts above confidence threshold', () => {
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'Vault uses folders.',
|
||||
category: 'vault_structure',
|
||||
confidence: 0.6,
|
||||
});
|
||||
manager.addLearnedFact({
|
||||
id: 'f2',
|
||||
timestamp: 2,
|
||||
content: 'Low confidence fact.',
|
||||
category: 'general',
|
||||
confidence: 0.3,
|
||||
});
|
||||
const ctx = manager.buildMemoryContext();
|
||||
expect(ctx).toContain('Learned Facts');
|
||||
expect(ctx).toContain('Vault uses folders.');
|
||||
expect(ctx).not.toContain('Low confidence fact.');
|
||||
});
|
||||
|
||||
it('should combine all sections', () => {
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'T',
|
||||
summary: 'S',
|
||||
keyPoints: ['k'],
|
||||
});
|
||||
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'Fact',
|
||||
category: 'general',
|
||||
confidence: 0.9,
|
||||
});
|
||||
const ctx = manager.buildMemoryContext();
|
||||
expect(ctx).toContain('Past Conversations');
|
||||
expect(ctx).toContain('User Preferences');
|
||||
expect(ctx).toContain('Learned Facts');
|
||||
});
|
||||
|
||||
it('should limit summaries to last 3', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.addConversationSummary({
|
||||
id: `s${i}`,
|
||||
timestamp: i,
|
||||
topic: `Topic ${i}`,
|
||||
summary: `Summary ${i}`,
|
||||
keyPoints: [`k${i}`],
|
||||
});
|
||||
}
|
||||
const ctx = manager.buildMemoryContext();
|
||||
expect(ctx).toContain('Topic 2');
|
||||
expect(ctx).toContain('Topic 4');
|
||||
expect(ctx).not.toContain('Topic 0');
|
||||
});
|
||||
|
||||
it('should limit facts to last 10', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
manager.addLearnedFact({
|
||||
id: `f${i}`,
|
||||
timestamp: i,
|
||||
content: `Fact ${i}`,
|
||||
category: 'general',
|
||||
confidence: 0.9,
|
||||
});
|
||||
}
|
||||
const ctx = manager.buildMemoryContext();
|
||||
const factMatches = ctx.match(/Fact \d+/g) ?? [];
|
||||
expect(factMatches.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPreferencesFromMessage', () => {
|
||||
it('should extract "I prefer" statements', () => {
|
||||
const prefs = manager.extractPreferencesFromMessage('I prefer dark mode.');
|
||||
expect(prefs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(prefs[0].value).toContain('dark mode');
|
||||
expect(prefs[0].source).toBe('inferred');
|
||||
});
|
||||
|
||||
it('should extract "I like" statements', () => {
|
||||
const prefs = manager.extractPreferencesFromMessage('I like coffee in the morning.');
|
||||
expect(prefs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(prefs[0].value).toContain('coffee');
|
||||
});
|
||||
|
||||
it('should extract "my favorite X is Y" statements', () => {
|
||||
const prefs = manager.extractPreferencesFromMessage('My favorite color is blue.');
|
||||
expect(prefs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(prefs[0].value).toContain('blue');
|
||||
});
|
||||
|
||||
it('should return empty array when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
const prefs = manager.extractPreferencesFromMessage('I like blue.');
|
||||
expect(prefs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for non-preference messages', () => {
|
||||
const prefs = manager.extractPreferencesFromMessage('What is the weather?');
|
||||
expect(prefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFactsFromMessage', () => {
|
||||
it('should extract vault folder paths', () => {
|
||||
const facts = manager.extractFactsFromMessage('Check the /projects/active/ folder.');
|
||||
expect(facts.some((f) => f.content.includes('/projects/active/'))).toBe(true);
|
||||
expect(facts.some((f) => f.category === 'vault_structure')).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract "X is a Y" topic facts', () => {
|
||||
const facts = manager.extractFactsFromMessage('Obsidian is a note-taking app.');
|
||||
expect(facts.some((f) => f.content.includes('Obsidian is'))).toBe(true);
|
||||
expect(facts.some((f) => f.category === 'topic')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not extract short subjects', () => {
|
||||
const facts = manager.extractFactsFromMessage('It is a thing.');
|
||||
expect(facts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
const facts = manager.extractFactsFromMessage('Obsidian is great.');
|
||||
expect(facts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation', () => {
|
||||
it('should derive topic from first user message', () => {
|
||||
const messages: OllamaMessage[] = [
|
||||
{ role: 'user', content: 'Tell me about quantum physics please' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Quantum physics is fascinating. It deals with subatomic particles.',
|
||||
},
|
||||
];
|
||||
const { topic, keyPoints } = manager.summarizeConversation(messages);
|
||||
expect(topic).toContain('Tell me about quantum physics');
|
||||
expect(keyPoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fallback to Untitled when no user message', () => {
|
||||
const messages: OllamaMessage[] = [{ role: 'assistant', content: 'Hello there.' }];
|
||||
const { topic } = manager.summarizeConversation(messages);
|
||||
expect(topic).toBe('Untitled conversation');
|
||||
});
|
||||
|
||||
it('should limit key points to 3', () => {
|
||||
const messages: OllamaMessage[] = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Point one. Point two. Point three. Point four. Point five.',
|
||||
},
|
||||
];
|
||||
const { keyPoints } = manager.summarizeConversation(messages);
|
||||
expect(keyPoints.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should extract sentences between 10 and 120 chars', () => {
|
||||
const messages: OllamaMessage[] = [
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'A. This is a reasonably sized sentence about topics.' },
|
||||
];
|
||||
const { keyPoints } = manager.summarizeConversation(messages);
|
||||
expect(keyPoints.every((k) => k.length >= 10 && k.length < 120)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('getConversationSummaries should return a copy', () => {
|
||||
manager.addConversationSummary({
|
||||
id: 's1',
|
||||
timestamp: 1,
|
||||
topic: 'T',
|
||||
summary: 'S',
|
||||
keyPoints: ['k'],
|
||||
});
|
||||
const summaries = manager.getConversationSummaries();
|
||||
summaries.push({ id: 's2', timestamp: 2, topic: 'T2', summary: 'S2', keyPoints: ['k2'] });
|
||||
expect(manager.getConversationSummaries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getUserPreferences should return a copy', () => {
|
||||
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
|
||||
const prefs = manager.getUserPreferences();
|
||||
prefs.push({ key: 'k2', value: 'v2', timestamp: 2, source: 'explicit' });
|
||||
expect(manager.getUserPreferences()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getLearnedFacts should return a copy', () => {
|
||||
manager.addLearnedFact({
|
||||
id: 'f1',
|
||||
timestamp: 1,
|
||||
content: 'C',
|
||||
category: 'general',
|
||||
confidence: 0.5,
|
||||
});
|
||||
const facts = manager.getLearnedFacts();
|
||||
facts.push({ id: 'f2', timestamp: 2, content: 'C2', category: 'general', confidence: 0.5 });
|
||||
expect(manager.getLearnedFacts()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+820
-8
@@ -1,16 +1,24 @@
|
||||
import { ToolExecutor } from '../src/tool-executor';
|
||||
import { TFile } from 'obsidian';
|
||||
import { TFile, TFolder } from 'obsidian';
|
||||
import { ToolCall, ToolResult } from '../src/types';
|
||||
import { ErrorHandler } from '../src/error-handler';
|
||||
import { TelemetryManager } from '../src/tool-telemetry';
|
||||
|
||||
// Mock Obsidian types
|
||||
interface MockVault {
|
||||
create: (path: string, content: string) => Promise<any>;
|
||||
createFolder: (path: string) => Promise<any>;
|
||||
getAbstractFileByPath: (path: string) => any;
|
||||
cachedRead: (file: any) => Promise<string>;
|
||||
getMarkdownFiles: () => any[];
|
||||
modify: (file: any, content: string) => Promise<void>;
|
||||
rename: (file: any, newPath: string) => Promise<void>;
|
||||
trash: (file: any, system: boolean) => Promise<void>;
|
||||
}
|
||||
interface MockApp {
|
||||
metadataCache: {
|
||||
getFileCache: jest.Mock;
|
||||
};
|
||||
// Mock app properties if needed
|
||||
}
|
||||
interface MockNotice {
|
||||
@@ -25,6 +33,7 @@ jest.mock('obsidian', () => {
|
||||
App: jest.fn(),
|
||||
Notice: jest.fn(),
|
||||
TFile,
|
||||
TFolder: class TFolder {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -43,11 +52,19 @@ describe('ToolExecutor', () => {
|
||||
beforeEach(() => {
|
||||
mockVault = {
|
||||
create: jest.fn().mockResolvedValue(null),
|
||||
createFolder: jest.fn().mockResolvedValue(null),
|
||||
getAbstractFileByPath: jest.fn(),
|
||||
cachedRead: jest.fn().mockResolvedValue(''),
|
||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||
modify: jest.fn().mockResolvedValue(undefined),
|
||||
rename: jest.fn().mockResolvedValue(undefined),
|
||||
trash: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockApp = {} as MockApp;
|
||||
mockApp = {
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
} as MockApp;
|
||||
executor = new ToolExecutor(mockVault as unknown as any, mockApp as unknown as any);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -67,7 +84,7 @@ describe('ToolExecutor', () => {
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content');
|
||||
});
|
||||
|
||||
@@ -84,11 +101,12 @@ describe('ToolExecutor', () => {
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('obj-args-file.md', 'Object args content');
|
||||
});
|
||||
|
||||
it('should successfully create a file in a subdirectory', async () => {
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
|
||||
const call: ToolCall = {
|
||||
id: 'call_3',
|
||||
type: 'function',
|
||||
@@ -101,13 +119,33 @@ describe('ToolExecutor', () => {
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.createFolder).toHaveBeenCalledWith('subdirectory');
|
||||
expect(mockVault.create).toHaveBeenCalledWith(
|
||||
'subdirectory/test-file.md',
|
||||
'Subdir content'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not recreate existing parent folders', async () => {
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new TFolder());
|
||||
const call: ToolCall = {
|
||||
id: 'call_existing_folder',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'existing/test-file.md',
|
||||
content: 'Subdir content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.createFolder).not.toHaveBeenCalled();
|
||||
expect(mockVault.create).toHaveBeenCalledWith('existing/test-file.md', 'Subdir content');
|
||||
});
|
||||
|
||||
it('should handle multiple slashes gracefully by normalizing path', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_4',
|
||||
@@ -121,7 +159,7 @@ describe('ToolExecutor', () => {
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content');
|
||||
});
|
||||
|
||||
@@ -138,7 +176,7 @@ describe('ToolExecutor', () => {
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', '');
|
||||
});
|
||||
|
||||
@@ -155,7 +193,7 @@ describe('ToolExecutor', () => {
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'File created successfully' });
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content');
|
||||
});
|
||||
|
||||
@@ -175,6 +213,38 @@ describe('ToolExecutor', () => {
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject exact forbidden directory paths', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_forbidden_exact',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: '.obsidian',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject nested forbidden directory paths', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_forbidden_nested',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({
|
||||
path: 'Notes/.git',
|
||||
content: 'Test content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow();
|
||||
expect(mockVault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts with .\\', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_8',
|
||||
@@ -711,6 +781,57 @@ describe('ToolExecutor', () => {
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.message).toContain('Found 0 matching files');
|
||||
});
|
||||
|
||||
it('should use VaultIndexer for rich search when available', async () => {
|
||||
const mockIndexer = {
|
||||
searchVault: jest.fn().mockResolvedValue([
|
||||
{
|
||||
path: 'Projects/AI/ml-basics.md',
|
||||
title: 'Machine Learning Basics',
|
||||
content: 'Intro to ML...',
|
||||
score: 0.95,
|
||||
tags: 'ai, ml, tutorial',
|
||||
},
|
||||
{
|
||||
path: 'Projects/AI/deep-learning.md',
|
||||
title: 'Deep Learning',
|
||||
content: 'Neural networks...',
|
||||
score: 0.88,
|
||||
tags: 'ai, neural-networks',
|
||||
},
|
||||
]),
|
||||
};
|
||||
const indexedExecutor = new ToolExecutor(
|
||||
mockVault as unknown as any,
|
||||
mockApp as unknown as any,
|
||||
undefined,
|
||||
mockIndexer as unknown as any
|
||||
);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_39',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_vault_files',
|
||||
arguments: JSON.stringify({
|
||||
query: 'machine learning',
|
||||
limit: 5,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await indexedExecutor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(mockIndexer.searchVault).toHaveBeenCalledWith('machine learning', 5);
|
||||
const firstResult = (result.data as any[])[0];
|
||||
expect(firstResult).toMatchObject({
|
||||
path: 'Projects/AI/ml-basics.md',
|
||||
basename: 'ml-basics.md',
|
||||
title: 'Machine Learning Basics',
|
||||
score: 0.95,
|
||||
tags: 'ai, ml, tutorial',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool method', () => {
|
||||
@@ -779,5 +900,696 @@ describe('ToolExecutor', () => {
|
||||
expect(result).toEqual({ success: false, message: 'Unknown tool: unknown_tool' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('create_note tool', () => {
|
||||
it('should create a note successfully', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_cn1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
arguments: JSON.stringify({
|
||||
path: 'New Note.md',
|
||||
content: '# Hello\nWorld',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||
expect(mockVault.create).toHaveBeenCalledWith('New Note.md', '# Hello\nWorld');
|
||||
});
|
||||
});
|
||||
|
||||
describe('append_to_note tool', () => {
|
||||
it('should append content to an existing note', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('Existing content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_an1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'append_to_note',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
content: 'Appended text',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Content appended successfully');
|
||||
expect(mockVault.modify).toHaveBeenCalled();
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toBe('Existing content\nAppended text');
|
||||
});
|
||||
|
||||
it('should append without extra newline if content already ends with newline', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('Existing content\n');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_an2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'append_to_note',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
content: 'Appended text',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await executor.handleToolCall(call);
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toBe('Existing content\nAppended text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace_note_section tool', () => {
|
||||
it('should replace a section under a heading', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const originalContent = `# Title\n\n## Section A\nOld content\n\n## Section B\nOther content`;
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_rs1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'replace_note_section',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
heading: 'Section A',
|
||||
content: 'New content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Section "Section A" replaced successfully');
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toContain('New content');
|
||||
expect(modifiedContent).not.toContain('Old content');
|
||||
expect(modifiedContent).toContain('## Section B');
|
||||
});
|
||||
|
||||
it('should throw error when heading not found', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('# Title\nBody');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_rs2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'replace_note_section',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
heading: 'Missing Section',
|
||||
content: 'New content',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow(
|
||||
'Heading "Missing Section" not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update_frontmatter tool', () => {
|
||||
it('should update existing frontmatter fields', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const originalContent = '---\ntitle: Old Title\ntags: idea\n---\nBody';
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_uf1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_frontmatter',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
fields: { title: 'New Title', status: 'done' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toContain('title: New Title');
|
||||
expect(modifiedContent).toContain('tags: idea');
|
||||
expect(modifiedContent).toContain('status: done');
|
||||
});
|
||||
|
||||
it('should create frontmatter if none exists', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('Just body content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_uf2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_frontmatter',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
fields: { title: 'New Note' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
await executor.handleToolCall(call);
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toContain('---');
|
||||
expect(modifiedContent).toContain('title: New Note');
|
||||
expect(modifiedContent).toContain('Just body content');
|
||||
});
|
||||
|
||||
it('should remove a field when set to null', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const originalContent = '---\ntitle: Note\ndraft: true\n---\nBody';
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('note.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue(originalContent);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_uf3',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_frontmatter',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
fields: { draft: null },
|
||||
}),
|
||||
},
|
||||
};
|
||||
await executor.handleToolCall(call);
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toContain('title: Note');
|
||||
expect(modifiedContent).not.toContain('draft: true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename_note tool', () => {
|
||||
it('should rename a note', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const file = new MockTFile('old.md');
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_rn1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'rename_note',
|
||||
arguments: JSON.stringify({
|
||||
oldPath: 'old.md',
|
||||
newPath: 'new.md',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockVault.rename).toHaveBeenCalledWith(file, 'new.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('move_note tool', () => {
|
||||
it('should move a note into a folder', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const file = new MockTFile('Projects/old.md');
|
||||
mockVault.getAbstractFileByPath = jest
|
||||
.fn()
|
||||
.mockImplementation((path: string) => (path === 'Projects/old.md' ? file : null));
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_mn1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'move_note',
|
||||
arguments: JSON.stringify({
|
||||
path: 'Projects/old.md',
|
||||
folder: 'Archive',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockVault.createFolder).toHaveBeenCalledWith('Archive');
|
||||
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
|
||||
});
|
||||
|
||||
it('should reject moving a note into a forbidden folder', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_mn_forbidden',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'move_note',
|
||||
arguments: JSON.stringify({
|
||||
path: 'Projects/old.md',
|
||||
folder: '.obsidian',
|
||||
}),
|
||||
},
|
||||
};
|
||||
await expect(executor.handleToolCall(call)).rejects.toThrow('Invalid folder path detected');
|
||||
expect(mockVault.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete_note tool', () => {
|
||||
it('should delete a note', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const file = new MockTFile('note.md');
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_dn1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_note',
|
||||
arguments: JSON.stringify({
|
||||
path: 'note.md',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockVault.trash).toHaveBeenCalledWith(file, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insert_link tool', () => {
|
||||
it('should insert a wikilink without anchor text', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('source.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('Source content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_il1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'insert_link',
|
||||
arguments: JSON.stringify({
|
||||
sourcePath: 'source.md',
|
||||
targetPath: 'target.md',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toContain('[[target.md]]');
|
||||
});
|
||||
|
||||
it('should insert a wikilink with anchor text', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('source.md'));
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('Source content');
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_il2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'insert_link',
|
||||
arguments: JSON.stringify({
|
||||
sourcePath: 'source.md',
|
||||
targetPath: 'target.md',
|
||||
anchorText: 'My Target',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
const modifiedContent = (mockVault.modify as jest.Mock).mock.calls[0][1];
|
||||
expect(modifiedContent).toContain('[[target.md|My Target]]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_vault_tags tool', () => {
|
||||
it('should list all tags sorted by name', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const files = [new MockTFile('a.md'), new MockTFile('b.md'), new MockTFile('c.md')];
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
|
||||
if (f.path === 'a.md') return { frontmatter: { tags: ['project', 'alpha'] } };
|
||||
if (f.path === 'b.md') return { frontmatter: { tags: 'project, beta' } };
|
||||
return { frontmatter: {} };
|
||||
});
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_lt1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_vault_tags',
|
||||
arguments: JSON.stringify({ sortBy: 'name' }),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any[]).length).toBe(3);
|
||||
expect((result.data as any[])[0].tag).toBe('alpha');
|
||||
expect((result.data as any[])[1].tag).toBe('beta');
|
||||
expect((result.data as any[])[2].tag).toBe('project');
|
||||
expect((result.data as any[])[2].count).toBe(2);
|
||||
});
|
||||
|
||||
it('should sort tags by count', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
}
|
||||
}
|
||||
const files = [new MockTFile('a.md'), new MockTFile('b.md')];
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
|
||||
if (f.path === 'a.md') return { frontmatter: { tags: ['common', 'rare'] } };
|
||||
if (f.path === 'b.md') return { frontmatter: { tags: ['common'] } };
|
||||
return { frontmatter: {} };
|
||||
});
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_lt2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_vault_tags',
|
||||
arguments: JSON.stringify({ sortBy: 'count' }),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any[];
|
||||
expect(data[0].tag).toBe('common');
|
||||
expect(data[0].count).toBe(2);
|
||||
expect(data[1].tag).toBe('rare');
|
||||
expect(data[1].count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_vault_stats tool', () => {
|
||||
it('should return vault overview stats', async () => {
|
||||
class MockTFile extends TFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
name: string;
|
||||
constructor(path: string, mtime?: number) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.basename = path.split('/').pop() || path;
|
||||
this.extension = this.basename.split('.').pop() || '';
|
||||
this.name = this.basename;
|
||||
if (mtime) {
|
||||
(this as any).stat = { mtime, ctime: mtime, size: 100 };
|
||||
}
|
||||
}
|
||||
}
|
||||
const files = [
|
||||
new MockTFile('Projects/alpha.md', 1000),
|
||||
new MockTFile('Projects/beta.md', 2000),
|
||||
new MockTFile('notes/daily.md', 1500),
|
||||
];
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||
mockVault.cachedRead = jest.fn().mockResolvedValue('content');
|
||||
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
|
||||
if (f.path === 'Projects/alpha.md') return { frontmatter: { tags: ['project'] } };
|
||||
if (f.path === 'Projects/beta.md') return { frontmatter: { tags: ['project', 'done'] } };
|
||||
return { frontmatter: {} };
|
||||
});
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_vs1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_vault_stats',
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
};
|
||||
const result = await executor.handleToolCall(call);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as any;
|
||||
expect(data.totalNotes).toBe(3);
|
||||
expect(data.totalFolders).toBe(2);
|
||||
expect(data.folders).toContain('Projects');
|
||||
expect(data.folders).toContain('notes');
|
||||
expect(data.taggedNotes).toBe(2);
|
||||
expect(data.untaggedNotes).toBe(1);
|
||||
expect(data.topTags).toHaveLength(2);
|
||||
expect(data.topTags[0].tag).toBe('project');
|
||||
expect(data.topTags[0].count).toBe(2);
|
||||
expect(data.recentFiles[0]).toBe('Projects/beta.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry integration', () => {
|
||||
let telemetryManager: TelemetryManager;
|
||||
let telemetryExecutor: ToolExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
telemetryManager = new TelemetryManager({ enabled: true, maxEntries: 100 });
|
||||
telemetryExecutor = new ToolExecutor(
|
||||
mockVault as unknown as any,
|
||||
mockApp as unknown as any,
|
||||
telemetryManager
|
||||
);
|
||||
});
|
||||
|
||||
it('should record successful tool calls in telemetry', async () => {
|
||||
mockVault.create = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_t1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({ path: 'test.md', content: 'hello' }),
|
||||
},
|
||||
};
|
||||
|
||||
await telemetryExecutor.handleToolCall(call);
|
||||
const entries = telemetryManager.getEntriesByType('tool_call');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).toolName).toBe('create_file');
|
||||
expect((entries[0] as any).success).toBe(true);
|
||||
expect((entries[0] as any).durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should record failed tool calls in telemetry', async () => {
|
||||
const call: ToolCall = {
|
||||
id: 'call_t2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({ path: '/invalid/path.md', content: 'hello' }),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(telemetryExecutor.handleToolCall(call)).rejects.toThrow();
|
||||
const entries = telemetryManager.getEntriesByType('tool_call');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).toolName).toBe('create_file');
|
||||
expect((entries[0] as any).success).toBe(false);
|
||||
});
|
||||
|
||||
it('should include parsed args in telemetry', async () => {
|
||||
mockVault.create = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_t3',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({ path: 'note.md', content: 'data' }),
|
||||
},
|
||||
};
|
||||
|
||||
await telemetryExecutor.handleToolCall(call);
|
||||
const entries = telemetryManager.getEntriesByType('tool_call');
|
||||
expect((entries[0] as any).args).toEqual({ path: 'note.md', content: 'data' });
|
||||
});
|
||||
|
||||
it('should not record telemetry when telemetry manager is undefined', async () => {
|
||||
const noTelemetryExecutor = new ToolExecutor(
|
||||
mockVault as unknown as any,
|
||||
mockApp as unknown as any
|
||||
);
|
||||
mockVault.create = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const call: ToolCall = {
|
||||
id: 'call_t4',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
arguments: JSON.stringify({ path: 'x.md', content: 'y' }),
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await noTelemetryExecutor.handleToolCall(call);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { TelemetryManager, createDefaultToolTelemetryData } from '../src/tool-telemetry';
|
||||
import type { ToolTelemetryConfig } from '../src/types';
|
||||
|
||||
describe('createDefaultToolTelemetryData', () => {
|
||||
it('should return empty entries array', () => {
|
||||
const data = createDefaultToolTelemetryData();
|
||||
expect(data.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TelemetryManager', () => {
|
||||
const defaultConfig: ToolTelemetryConfig = {
|
||||
enabled: true,
|
||||
maxEntries: 3,
|
||||
};
|
||||
|
||||
let manager: TelemetryManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new TelemetryManager(defaultConfig);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with empty data when no initial data provided', () => {
|
||||
expect(manager.getData().entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with provided data', () => {
|
||||
const initialData = createDefaultToolTelemetryData();
|
||||
initialData.entries.push({
|
||||
id: 'e1',
|
||||
timestamp: 1,
|
||||
type: 'tool_call',
|
||||
toolName: 'read_vault_file',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
const m = new TelemetryManager(defaultConfig, initialData);
|
||||
expect(m.getData().entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadData and getData', () => {
|
||||
it('should load and return data round-trip', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 5,
|
||||
});
|
||||
|
||||
const loaded = manager.getData();
|
||||
expect(loaded.entries).toHaveLength(1);
|
||||
|
||||
const newManager = new TelemetryManager(defaultConfig);
|
||||
newManager.loadData(loaded);
|
||||
expect(newManager.getData().entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('should enforce new limits after config update', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.recordToolCall({
|
||||
toolName: `tool-${i}`,
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: i,
|
||||
});
|
||||
}
|
||||
expect(manager.getData().entries).toHaveLength(3);
|
||||
|
||||
manager.updateConfig({ ...defaultConfig, maxEntries: 2 });
|
||||
expect(manager.getData().entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should disable writes when enabled becomes false', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 5,
|
||||
});
|
||||
expect(manager.getData().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordToolCall', () => {
|
||||
it('should add a tool call entry', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'create_note',
|
||||
args: { path: 'test.md' },
|
||||
success: true,
|
||||
resultSummary: 'Created',
|
||||
durationMs: 100,
|
||||
});
|
||||
const entries = manager.getEntriesByType('tool_call');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).toolName).toBe('create_note');
|
||||
expect((entries[0] as any).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should enforce maxEntries limit keeping newest', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.recordToolCall({
|
||||
toolName: `tool-${i}`,
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: i,
|
||||
});
|
||||
}
|
||||
const entries = manager.getData().entries;
|
||||
expect(entries).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should not add when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 5,
|
||||
});
|
||||
expect(manager.getData().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordLlmCall', () => {
|
||||
it('should add an LLM call entry', () => {
|
||||
manager.recordLlmCall({
|
||||
model: 'llama3',
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
totalTokens: 150,
|
||||
durationMs: 2000,
|
||||
});
|
||||
const entries = manager.getEntriesByType('llm_call');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).model).toBe('llama3');
|
||||
expect((entries[0] as any).totalTokens).toBe(150);
|
||||
});
|
||||
|
||||
it('should not add when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.recordLlmCall({
|
||||
model: 'llama3',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
durationMs: 100,
|
||||
});
|
||||
expect(manager.getData().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordSearch', () => {
|
||||
it('should add a search entry', () => {
|
||||
manager.recordSearch({
|
||||
query: 'test',
|
||||
resultsCount: 3,
|
||||
resultPaths: ['a.md', 'b.md'],
|
||||
durationMs: 50,
|
||||
});
|
||||
const entries = manager.getEntriesByType('vault_search');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).query).toBe('test');
|
||||
expect((entries[0] as any).resultsCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should not add when disabled', () => {
|
||||
manager.updateConfig({ ...defaultConfig, enabled: false });
|
||||
manager.recordSearch({
|
||||
query: 'test',
|
||||
resultsCount: 0,
|
||||
resultPaths: [],
|
||||
durationMs: 10,
|
||||
});
|
||||
expect(manager.getData().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentEntries', () => {
|
||||
it('should return entries sorted by timestamp descending', async () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'first',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
manager.recordToolCall({
|
||||
toolName: 'second',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
const recent = manager.getRecentEntries();
|
||||
expect((recent[0] as any).toolName).toBe('second');
|
||||
expect((recent[1] as any).toolName).toBe('first');
|
||||
});
|
||||
|
||||
it('should respect limit parameter', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'first',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
manager.recordToolCall({
|
||||
toolName: 'second',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
expect(manager.getRecentEntries(1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntriesByType', () => {
|
||||
it('should filter by type', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
manager.recordLlmCall({
|
||||
model: 'llama3',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
durationMs: 100,
|
||||
});
|
||||
expect(manager.getEntriesByType('tool_call')).toHaveLength(1);
|
||||
expect(manager.getEntriesByType('llm_call')).toHaveLength(1);
|
||||
expect(manager.getEntriesByType('vault_search')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should remove all entries', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
manager.clear();
|
||||
expect(manager.getData().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('getData should return a copy', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
const data = manager.getData();
|
||||
data.entries.push({
|
||||
id: 'x',
|
||||
timestamp: 1,
|
||||
type: 'tool_call',
|
||||
toolName: 'injected',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 1,
|
||||
});
|
||||
expect(manager.getData().entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getEntriesByType should return a copy', () => {
|
||||
manager.recordToolCall({
|
||||
toolName: 'test',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 10,
|
||||
});
|
||||
const entries = manager.getEntriesByType('tool_call');
|
||||
entries.push({
|
||||
id: 'x',
|
||||
timestamp: 1,
|
||||
type: 'tool_call',
|
||||
toolName: 'injected',
|
||||
args: {},
|
||||
success: true,
|
||||
resultSummary: 'ok',
|
||||
durationMs: 1,
|
||||
});
|
||||
expect(manager.getEntriesByType('tool_call')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+180
-9
@@ -286,8 +286,8 @@ describe('VaultIndexer', () => {
|
||||
basename: 'test',
|
||||
path: 'test.md',
|
||||
} as any);
|
||||
const score = (indexer as any).calculateWeightedScore(tokenized, queryTokens);
|
||||
expect(score.score).toBe(0);
|
||||
const score = (indexer as any).calculateWeightedScore(tokenized, queryTokens, []);
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it('should score higher when more tokens match', () => {
|
||||
@@ -300,13 +300,15 @@ describe('VaultIndexer', () => {
|
||||
const query2 = 'algorithm design pattern';
|
||||
const score1 = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
(indexer as any).tokenize(query1)
|
||||
(indexer as any).tokenize(query1),
|
||||
[]
|
||||
);
|
||||
const score2 = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
(indexer as any).tokenize(query2)
|
||||
(indexer as any).tokenize(query2),
|
||||
[]
|
||||
);
|
||||
expect(score2.score).toBeGreaterThan(score1.score);
|
||||
expect(score2).toBeGreaterThan(score1);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
@@ -318,9 +320,10 @@ describe('VaultIndexer', () => {
|
||||
const query = 'important algorithm';
|
||||
const score = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
(indexer as any).tokenize(query)
|
||||
(indexer as any).tokenize(query),
|
||||
[]
|
||||
);
|
||||
expect(score.score).toBeGreaterThan(0);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle word boundary matching', () => {
|
||||
@@ -332,9 +335,10 @@ describe('VaultIndexer', () => {
|
||||
const query = 'algorithm';
|
||||
const score = (indexer as any).calculateWeightedScore(
|
||||
tokenized,
|
||||
(indexer as any).tokenize(query)
|
||||
(indexer as any).tokenize(query),
|
||||
[]
|
||||
);
|
||||
expect(score.score).toBeGreaterThan(0);
|
||||
expect(score).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,4 +396,171 @@ describe('VaultIndexer', () => {
|
||||
expect(tokenized.firstParagraph).not.toContain('Second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractExactPhrases', () => {
|
||||
it('should extract quoted phrases', () => {
|
||||
const phrases = (indexer as any).extractExactPhrases('search "exact phrase" here');
|
||||
expect(phrases).toEqual(['exact phrase']);
|
||||
});
|
||||
|
||||
it('should extract multiple quoted phrases', () => {
|
||||
const phrases = (indexer as any).extractExactPhrases('"phrase one" and "phrase two"');
|
||||
expect(phrases).toEqual(['phrase one', 'phrase two']);
|
||||
});
|
||||
|
||||
it('should return empty array when no quotes', () => {
|
||||
const phrases = (indexer as any).extractExactPhrases('no quotes here');
|
||||
expect(phrases).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchVault with options', () => {
|
||||
it('should boost exact phrase matches', async () => {
|
||||
const file1: MockTFile = { basename: 'a', path: 'a.md' };
|
||||
const file2: MockTFile = { basename: 'b', path: 'b.md' };
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('The quick brown fox jumps over the lazy dog')
|
||||
.mockResolvedValueOnce('The quick brown fox');
|
||||
|
||||
const results = await indexer.searchVault('"lazy dog"', 5);
|
||||
// The file with the exact phrase should rank higher
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
if (results.length >= 2) {
|
||||
expect(results[0].score).toBeGreaterThanOrEqual(results[1].score);
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter by folder', async () => {
|
||||
const file1: MockTFile = { basename: 'a', path: 'Projects/a.md' };
|
||||
const file2: MockTFile = { basename: 'b', path: 'Archive/b.md' };
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('important keyword here')
|
||||
.mockResolvedValueOnce('completely unrelated text');
|
||||
|
||||
const results = await indexer.searchVault('important keyword', 5, { folder: 'Projects' });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].path).toBe('Projects/a.md');
|
||||
});
|
||||
|
||||
it('should filter by tag', async () => {
|
||||
const file1: MockTFile = { basename: 'a', path: 'a.md' };
|
||||
const file2: MockTFile = { basename: 'b', path: 'b.md' };
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('---\ntags: idea\n---\nimportant keyword')
|
||||
.mockResolvedValueOnce('---\ntags: done\n---\nother unrelated content');
|
||||
|
||||
const results = await indexer.searchVault('important keyword', 5, { tag: 'idea' });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].path).toBe('a.md');
|
||||
});
|
||||
|
||||
it('should apply recency boost', async () => {
|
||||
const now = Date.now();
|
||||
const file1: MockTFile = { basename: 'a', path: 'a.md' };
|
||||
const file2: MockTFile = { basename: 'b', path: 'b.md' };
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file1, file2]);
|
||||
mockVault.read = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('important keyword')
|
||||
.mockResolvedValueOnce('important keyword');
|
||||
|
||||
// Mock stat with different mtimes
|
||||
(file1 as any).stat = { mtime: now - 86400000 }; // 1 day ago
|
||||
(file2 as any).stat = { mtime: now - 86400000 * 100 }; // 100 days ago
|
||||
|
||||
const results = await indexer.searchVault('important keyword', 5, { recencyBoost: true });
|
||||
expect(results.length).toBe(2);
|
||||
// The more recent file should have a higher score
|
||||
expect(results[0].score).toBeGreaterThanOrEqual(results[1].score);
|
||||
});
|
||||
|
||||
it('should build a cache key with options', () => {
|
||||
const key1 = (indexer as any).buildCacheKey('test', 5, { folder: 'Projects', tag: 'idea' });
|
||||
expect(key1).toContain('folder:Projects');
|
||||
expect(key1).toContain('tag:idea');
|
||||
|
||||
const key2 = (indexer as any).buildCacheKey('test', 5, { recencyBoost: false });
|
||||
expect(key2).toContain('norecency');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with metadataCache', () => {
|
||||
it('should parse YAML array tags from metadataCache', async () => {
|
||||
const file = {
|
||||
basename: 'Note A',
|
||||
path: 'projects/note-a.md',
|
||||
};
|
||||
const mockApp = {
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockReturnValue({
|
||||
frontmatter: {
|
||||
title: 'Project Alpha',
|
||||
tags: ['project', 'alpha', 'urgent'],
|
||||
},
|
||||
headings: [{ heading: 'Project Alpha' }, { heading: 'Overview' }],
|
||||
}),
|
||||
},
|
||||
};
|
||||
const indexedWithApp = new VaultIndexer(mockVault as unknown as any);
|
||||
indexedWithApp.setApp(mockApp as unknown as any);
|
||||
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||
mockVault.read = jest
|
||||
.fn()
|
||||
.mockResolvedValue('# Project Alpha\n\nSome content here.\n\n## Overview\n\nMore text.');
|
||||
|
||||
const results = await indexedWithApp.searchVault('alpha', 5);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('Project Alpha');
|
||||
expect(results[0].tags).toBe('project, alpha, urgent');
|
||||
});
|
||||
|
||||
it('should fall back to regex parsing when metadataCache is unavailable', async () => {
|
||||
const file = {
|
||||
basename: 'Note B',
|
||||
path: 'note-b.md',
|
||||
};
|
||||
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||
mockVault.read = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
'---\ntitle: Legacy Note\ntags: legacy, old\n---\n\n# Legacy Note\n\nContent here.'
|
||||
);
|
||||
|
||||
const results = await indexer.searchVault('legacy', 5);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('Legacy Note');
|
||||
expect(results[0].tags).toBe('legacy, old');
|
||||
});
|
||||
|
||||
it('should use metadataCache headings when available', () => {
|
||||
const file = {
|
||||
basename: 'Note C',
|
||||
path: 'note-c.md',
|
||||
};
|
||||
const mockApp = {
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockReturnValue({
|
||||
frontmatter: {},
|
||||
headings: [{ heading: 'First Heading' }, { heading: 'Second Heading' }],
|
||||
}),
|
||||
},
|
||||
};
|
||||
const indexedWithApp = new VaultIndexer(mockVault as unknown as any);
|
||||
indexedWithApp.setApp(mockApp as unknown as any);
|
||||
|
||||
const tokenized = (indexedWithApp as any).tokenizeContent(
|
||||
'Some content\n\n# First Heading\n\n# Second Heading\n\nBody.',
|
||||
file
|
||||
);
|
||||
expect(tokenized.headings).toEqual(['First Heading', 'Second Heading']);
|
||||
expect(tokenized.title).toBe('First Heading');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { ChromaClient } from 'chromadb';
|
||||
import { VaultIndexConfig } from '../src/types';
|
||||
|
||||
// Mock ChromaDB module
|
||||
jest.mock('chromadb', () => ({
|
||||
ChromaClient: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
getOrCreateCollection: jest.fn().mockResolvedValue({
|
||||
query: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn().mockResolvedValue(5),
|
||||
}),
|
||||
deleteCollection: jest.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { VaultVectorStore } from '../src/vault-vector-store';
|
||||
|
||||
describe('VaultVectorStore', () => {
|
||||
const mockOllamaUrl = 'http://localhost:11434';
|
||||
const mockConfig: VaultIndexConfig = {
|
||||
enabled: true,
|
||||
similarityThreshold: 0.75,
|
||||
collectionName: 'test_vault_index',
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
chromaURL: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
let store: VaultVectorStore;
|
||||
let mockChromaClient: any;
|
||||
let mockCollection: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ embedding: [0.1, 0.2, 0.3] }),
|
||||
});
|
||||
|
||||
store = new VaultVectorStore(mockOllamaUrl, mockConfig);
|
||||
await store.initialize();
|
||||
|
||||
mockChromaClient = (ChromaClient as jest.Mock).mock.results[0].value;
|
||||
mockCollection = await mockChromaClient.getOrCreateCollection.mock.results[0].value;
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with provided config', () => {
|
||||
expect(store).toBeInstanceOf(VaultVectorStore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize the collection', async () => {
|
||||
expect(mockChromaClient.getOrCreateCollection).toHaveBeenCalledWith({
|
||||
name: mockConfig.collectionName,
|
||||
metadata: { 'hnsw:space': 'cosine' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not initialize when disabled', async () => {
|
||||
jest.clearAllMocks();
|
||||
const disabledConfig: VaultIndexConfig = { ...mockConfig, enabled: false };
|
||||
const disabledStore = new VaultVectorStore(mockOllamaUrl, disabledConfig);
|
||||
await disabledStore.initialize();
|
||||
expect(mockChromaClient.getOrCreateCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexFile', () => {
|
||||
it('should upsert a file into the collection', async () => {
|
||||
const mockFile = {
|
||||
basename: 'test.md',
|
||||
path: 'test.md',
|
||||
extension: 'md',
|
||||
} as any;
|
||||
|
||||
await store.indexFile(mockFile, '# Test\n\nThis is test content.');
|
||||
|
||||
expect(mockCollection.upsert).toHaveBeenCalled();
|
||||
const upsertCall = mockCollection.upsert.mock.calls[0][0];
|
||||
expect(upsertCall.ids).toContain('test.md');
|
||||
expect(upsertCall.metadatas[0].title).toBe('test');
|
||||
});
|
||||
|
||||
it('should delete file from index when content is empty', async () => {
|
||||
const mockFile = {
|
||||
basename: 'empty.md',
|
||||
path: 'empty.md',
|
||||
extension: 'md',
|
||||
} as any;
|
||||
|
||||
await store.indexFile(mockFile, ' ');
|
||||
expect(mockCollection.delete).toHaveBeenCalledWith({ ids: ['empty.md'] });
|
||||
});
|
||||
|
||||
it('should not index when collection is null', async () => {
|
||||
jest.clearAllMocks();
|
||||
const disabledConfig: VaultIndexConfig = { ...mockConfig, enabled: false };
|
||||
const disabledStore = new VaultVectorStore(mockOllamaUrl, disabledConfig);
|
||||
await disabledStore.initialize();
|
||||
|
||||
const mockFile = { basename: 'test.md', path: 'test.md' } as any;
|
||||
await disabledStore.indexFile(mockFile, 'content');
|
||||
expect(mockCollection.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should delete a file from the collection', async () => {
|
||||
await store.deleteFile('test.md');
|
||||
expect(mockCollection.delete).toHaveBeenCalledWith({ ids: ['test.md'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return empty array when disabled', async () => {
|
||||
jest.clearAllMocks();
|
||||
const disabledConfig: VaultIndexConfig = { ...mockConfig, enabled: false };
|
||||
const disabledStore = new VaultVectorStore(mockOllamaUrl, disabledConfig);
|
||||
await disabledStore.initialize();
|
||||
|
||||
const results = await disabledStore.search('test query', 3);
|
||||
expect(results).toEqual([]);
|
||||
expect(mockCollection.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return semantic search results', async () => {
|
||||
mockCollection.query.mockResolvedValue({
|
||||
ids: [['file1.md', 'file2.md']],
|
||||
documents: [['Doc 1 content', 'Doc 2 content']],
|
||||
distances: [[0.1, 0.15]],
|
||||
metadatas: [
|
||||
[
|
||||
{ path: 'file1.md', title: 'File 1' },
|
||||
{ path: 'file2.md', title: 'File 2' },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const results = await store.search('test query', 2);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].path).toBe('file1.md');
|
||||
expect(results[0].title).toBe('File 1');
|
||||
expect(results[0].score).toBe(0.9); // 1 - 0.1
|
||||
expect(results[1].score).toBe(0.85); // 1 - 0.15
|
||||
});
|
||||
|
||||
it('should filter results below similarity threshold', async () => {
|
||||
mockCollection.query.mockResolvedValue({
|
||||
ids: [['file1.md', 'file2.md']],
|
||||
documents: [['Doc 1', 'Doc 2']],
|
||||
distances: [[0.1, 0.5]], // scores: 0.9 and 0.5; threshold is 0.75
|
||||
metadatas: [
|
||||
[
|
||||
{ path: 'file1.md', title: 'File 1' },
|
||||
{ path: 'file2.md', title: 'File 2' },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const results = await store.search('test', 2);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].path).toBe('file1.md');
|
||||
});
|
||||
|
||||
it('should return empty array when no results', async () => {
|
||||
mockCollection.query.mockResolvedValue({
|
||||
ids: [[]],
|
||||
documents: [[]],
|
||||
distances: [[]],
|
||||
metadatas: [[]],
|
||||
});
|
||||
|
||||
const results = await store.search('test', 3);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on query failure', async () => {
|
||||
mockCollection.query.mockRejectedValue(new Error('Query failed'));
|
||||
|
||||
const results = await store.search('test', 3);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearIndex', () => {
|
||||
it('should delete the collection', async () => {
|
||||
await store.clearIndex();
|
||||
expect(mockChromaClient.deleteCollection).toHaveBeenCalledWith({
|
||||
name: mockConfig.collectionName,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not clear when disabled', async () => {
|
||||
jest.clearAllMocks();
|
||||
const disabledConfig: VaultIndexConfig = { ...mockConfig, enabled: false };
|
||||
const disabledStore = new VaultVectorStore(mockOllamaUrl, disabledConfig);
|
||||
await disabledStore.clearIndex();
|
||||
expect(mockChromaClient.deleteCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIndexedCount', () => {
|
||||
it('should return the collection count', async () => {
|
||||
const count = await store.getIndexedCount();
|
||||
expect(count).toBe(5);
|
||||
expect(mockCollection.count).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 0 when disabled', async () => {
|
||||
jest.clearAllMocks();
|
||||
const disabledConfig: VaultIndexConfig = { ...mockConfig, enabled: false };
|
||||
const disabledStore = new VaultVectorStore(mockOllamaUrl, disabledConfig);
|
||||
const count = await disabledStore.getIndexedCount();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -200,8 +200,7 @@ describe('ContentVectorizer', () => {
|
||||
expect(prompt).toContain('This is the first paragraph');
|
||||
expect(prompt).toContain('Main Heading');
|
||||
expect(prompt).toContain('Sub Heading');
|
||||
expect(prompt).toContain('test');
|
||||
expect(prompt).toContain('2024-01-01');
|
||||
// Frontmatter is no longer included in embedding prompts
|
||||
});
|
||||
|
||||
it('should handle empty content fields gracefully', () => {
|
||||
@@ -221,8 +220,7 @@ describe('ContentVectorizer', () => {
|
||||
|
||||
const prompt = (vectorizer as any).createPrompt(chunk);
|
||||
expect(prompt).toContain('Only content');
|
||||
// JSON.stringify({}) produces "{}", which is truthy so it's included
|
||||
expect(prompt).toContain('{}');
|
||||
// Frontmatter is no longer included in embedding prompts
|
||||
});
|
||||
|
||||
it('should limit content length', () => {
|
||||
@@ -243,7 +241,7 @@ describe('ContentVectorizer', () => {
|
||||
|
||||
const prompt = (vectorizer as any).createPrompt(chunk);
|
||||
expect(prompt).not.toContain('a'.repeat(1500));
|
||||
expect(prompt).toContain('a'.repeat(1000));
|
||||
expect(prompt).toContain('a'.repeat(500));
|
||||
});
|
||||
|
||||
it('should handle missing frontmatter gracefully', () => {
|
||||
|
||||
@@ -52,21 +52,18 @@ function createMockWorkflowEngine(
|
||||
|
||||
const engine = new WorkflowEngine(vault as any, app as any, 'http://localhost:11434', 'llama3');
|
||||
|
||||
// Access private properties via jest mocking
|
||||
const mockVaultIndexer = VaultIndexer as unknown as jest.Mocked<typeof VaultIndexer>;
|
||||
const mockToolExecutor = ToolExecutor as unknown as jest.Mocked<typeof ToolExecutor>;
|
||||
const mockOllamaClient = OllamaClient as unknown as jest.Mocked<typeof OllamaClient>;
|
||||
const mockConversationStateManager = ConversationStateManager as unknown as jest.Mocked<
|
||||
typeof ConversationStateManager
|
||||
>;
|
||||
// Access the actual mock instances created inside WorkflowEngine constructor
|
||||
const mockVaultIndexer = (engine as any).vaultIndexer as jest.Mocked<VaultIndexer>;
|
||||
const mockToolExecutor = (engine as any).toolExecutor as jest.Mocked<ToolExecutor>;
|
||||
const mockOllamaClient = (engine as any).ollamaClient as jest.Mocked<OllamaClient>;
|
||||
const mockConversationStateManager = (engine as any).conversationStateManager as jest.Mocked<ConversationStateManager>;
|
||||
|
||||
return {
|
||||
engine,
|
||||
mockVaultIndexer: mockVaultIndexer.prototype as unknown as jest.Mocked<VaultIndexer>,
|
||||
mockToolExecutor: mockToolExecutor.prototype as unknown as jest.Mocked<ToolExecutor>,
|
||||
mockOllamaClient: mockOllamaClient.prototype as unknown as jest.Mocked<OllamaClient>,
|
||||
mockConversationStateManager:
|
||||
mockConversationStateManager.prototype as unknown as jest.Mocked<ConversationStateManager>,
|
||||
mockVaultIndexer,
|
||||
mockToolExecutor,
|
||||
mockOllamaClient,
|
||||
mockConversationStateManager,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -534,18 +531,18 @@ describe('WorkflowEngine', () => {
|
||||
it('should execute vault search step successfully', async () => {
|
||||
const mockEntries = [
|
||||
{
|
||||
file: { path: 'meeting-note.md', basename: 'meeting-note' },
|
||||
path: 'meeting-note.md',
|
||||
title: 'Team Meeting',
|
||||
content: 'Meeting notes content',
|
||||
score: 10,
|
||||
frontmatter: { tags: 'meeting,team' },
|
||||
tags: 'meeting,team',
|
||||
},
|
||||
{
|
||||
file: { path: 'project-update.md', basename: 'project-update' },
|
||||
path: 'project-update.md',
|
||||
title: 'Project Update',
|
||||
content: 'Project progress notes',
|
||||
score: 8,
|
||||
frontmatter: { tags: 'meeting,project' },
|
||||
tags: 'meeting,project',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -581,18 +578,18 @@ describe('WorkflowEngine', () => {
|
||||
it('should apply tag filter in vault search', async () => {
|
||||
const mockEntries = [
|
||||
{
|
||||
file: { path: 'meeting-note.md', basename: 'meeting-note' },
|
||||
path: 'meeting-note.md',
|
||||
title: 'Team Meeting',
|
||||
content: 'Meeting notes',
|
||||
score: 10,
|
||||
frontmatter: { tags: 'meeting,team' },
|
||||
tags: 'meeting,team',
|
||||
},
|
||||
{
|
||||
file: { path: 'personal-note.md', basename: 'personal-note' },
|
||||
path: 'personal-note.md',
|
||||
title: 'Personal Note',
|
||||
content: 'Personal thoughts',
|
||||
score: 8,
|
||||
frontmatter: { tags: 'personal,daily' },
|
||||
tags: 'personal,daily',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -727,7 +724,7 @@ describe('WorkflowEngine', () => {
|
||||
it('should execute format step with variable interpolation', async () => {
|
||||
mockVaultIndexer.searchVault.mockResolvedValueOnce([
|
||||
{
|
||||
file: { path: 'note.md' },
|
||||
path: 'note.md',
|
||||
title: 'Test Note',
|
||||
content: 'Note content',
|
||||
score: 10,
|
||||
@@ -845,7 +842,7 @@ describe('WorkflowEngine', () => {
|
||||
it('should execute multiple steps in sequence', async () => {
|
||||
mockVaultIndexer.searchVault.mockResolvedValueOnce([
|
||||
{
|
||||
file: { path: 'note.md' },
|
||||
path: 'note.md',
|
||||
title: 'Test Note',
|
||||
content: 'Content',
|
||||
score: 10,
|
||||
@@ -994,7 +991,7 @@ describe('WorkflowEngine', () => {
|
||||
it('should handle initial variables correctly', async () => {
|
||||
mockVaultIndexer.searchVault.mockResolvedValueOnce([
|
||||
{
|
||||
file: { path: 'note.md' },
|
||||
path: 'note.md',
|
||||
title: 'Meeting',
|
||||
content: 'Content',
|
||||
score: 10,
|
||||
@@ -1083,7 +1080,7 @@ describe('WorkflowEngine', () => {
|
||||
it('should interpolate simple variables', async () => {
|
||||
mockVaultIndexer.searchVault.mockResolvedValueOnce([
|
||||
{
|
||||
file: { path: 'note.md' },
|
||||
path: 'note.md',
|
||||
title: 'Note',
|
||||
content: 'Content',
|
||||
score: 10,
|
||||
|
||||
Reference in New Issue
Block a user