56 Commits

Author SHA1 Message Date
fegger a573d33d0a Add undo manager and CoW semantics for tool execution
Replace the pending-actions preview flow with immediate execution and
undo support. ToolExecutor now accepts an UndoManager and records
create, modify, rename, and trash operations so users can roll back
batches.

Other fixes included:
- Deep-merge nested config objects on settings load to preserve new
  default fields
- Increase retry backoff from 10ms to 1000ms and widen the "invalid
  response format" check to handle prefixed messages
- Fix semantic cache clear to null out the collection reference
- Tighten memory regex to require "please always/never"
- Increase vault indexing batch size from 1 to 5
- Remove unused modeRequiresPreview helper
2026-05-26 09:50:23 +02:00
fegger d92a46ed8b Update README.md 2026-05-25 00:57:40 +02:00
fegger 3ab8542cf4 Fix semantic cache distance calculation, tool path validation, and add deferred settings persistence
- Convert Chroma cosine distance to similarity (1 - distance) for correct threshold comparison
- Simplify forbidden directory check to match path segments instead of string prefixes
- Add debounced settings save via onPersist callback in ChatView to prevent data loss
- Fix workflow engine to pass availableTools to LLM when includeToolCalls is enabled
- Add targetFolder support to AutoLinker config and update tests
- Harden vault file indexing to await background indexing before processing create/modify/rename events
2026-05-21 20:41:58 +02:00
fegger b7b3a185a0 Add automatic folder creation for file operations and improve chat session handling
Implement `ensureFolderExists` in `ToolExecutor` to create parent directories when creating, moving, or renaming notes.
Update `ChatView` to properly persist agent mode and derive session titles from the first user message. Fix error
handling in tool execution to return structured failures instead of null, and include failure details in follow-up
messages.
2026-05-21 20:35:05 +02:00
fegger d330b94816 Add stop button to cancel in-flight requests
Swap the Send button for a Stop button while a response is streaming, letting users abort the current operation cleanly.
Cancelled requests now display "Stopped." instead of an error message.

Also includes two smaller fixes: preserve the original assistant text and full tool_calls array when executing write
tools for follow-up context, and add 'research' to agentic modes.
2026-05-21 18:30:43 +02:00
fegger 3d9be7dfdb Simplify tool call nudging and refine action detection
Replace the 3-attempt retry loop with a single nudge when the model
explicitly mentioned taking action. Remove misleading nudges for models
that never expressed intent, which wasted round-trips and confused
responses. Also streamline the userWantsVaultOps phrase list to remove
weak triggers like "ok", "yes", and "please", and add fallback content
for empty responses.
2026-05-21 17:45:00 +02:00
fegger c2c2d51da5 Add recursive tool call handling with depth limit
This commit adds support for multi-turn tool calling by making processToolCalls
recursive. Previously, follow-up LLM responses that included additional tool
calls would drop those calls and present the text response instead.

Key changes:
- processToolCalls now accepts a depth parameter (default 0)
- Recurses when follow-up responses contain more tool_calls, up to MAX_TOOL_CALL_DEPTH
- Adds MAX_TOOL_CALL_DEPTH constant set to 5 to prevent infinite loops
- Clears pending actions only after processing is complete or before recursing
- Fixes new-chat container flex layout in styles.css to wrap and start from left

I need to write a clean git commit message for this diff. Let me analyze what changed:

1. In `main.js` and `src/chat-view.ts`:
   - `processToolCalls` now has a `depth` parameter with default `0`
   - Added `MAX_TOOL_CALL_DEPTH = 5` constant
   - Added depth check at start of `processToolCalls`
   - When follow-up LLM calls return more tool calls, it now recursively calls `processToolCalls` instead of ignoring
     them
   - `clearPendingActions()` moved around to be called at appropriate times
   - Some variable renames from `finalResponse` to `followUpContent` for clarity

2. In `styles.css`:
   - `.ollama-new-chat-container` changed from `justify-content: flex-end` to `flex-wrap: wrap; justify-content:
     flex-start`

The main feature is recursive/multi-turn tool calling with a depth limit. The CSS change seems like a minor UI fix
included in the same commit.

Subject line ideas:
- "Add recursive tool call handling with depth limit" (47 chars) - good
- "Support multi-turn tool calls with depth limit" (46 chars) - good
- "Add recursive tool calls and depth limit" (41 chars) - good
- "Implement recursive tool calling with depth limit" (47 chars) - good

Wait, I also need to mention the CSS change? It's minor but part of the commit. However, usually the subject should
capture the main change. If there are multiple unrelated changes, sometimes people mention them, but here the CSS change
seems minor enough that it could be in the body or omitted if not important. Actually looking at the diff, the CSS
change is just adjusting flex properties. It might be related to UI changes needed for the tool call flow, or it could
be an unrelated fix piggybacking.

Actually, the prompt says "write a short clear commit message that summarizes the changes." If I can express it in the
subject line, don't include body. But this seems like a significant enough change that a body might help explain the
"why" and key details.

Let me think about the body. The key points:
- Makes `processToolCalls` recursive to handle follow-up tool calls
- Adds `MAX_TOOL_CALL_DEPTH` (5) to prevent infinite recursion
- Previously, follow-up responses containing tool_calls would drop them
- Also fixes new-chat container layout

Subject: Add recursive tool call handling with depth limit (47 chars)

Body:
Support multi-turn tool calling by recursing in processToolCalls when
follow-up LLM responses contain additional tool_calls. Previously these
were ignored and the text response was shown instead.

Add MAX_TOOL_CALL_DEPTH constant set to 5 to prevent infinite loops.
Clear pending actions at the appropriate recursion boundaries.

Fix new-chat container flex layout to wrap and align left.

Wait, I should check if "Add recursive tool call handling with depth limit" is clear enough. Yes.

Let me count chars:
"Add recursive tool call handling with depth limit"
A-d-d- -r-e-c-u-r-s-i-v-e- -t-o-o-l- -c-a-l-l- -h-a-n-d-l-i-n-g- -w-i-t-h- -d-e-p-t-h- -l-i-m-i-t
3+1+9+1+4+1+4+1+9+1+4+1+5+1+5 = let's just count: "Add" (3) + " " (1) + "recursive" (9) + " " (1) + "tool" (4) + " " (1)
+ "call" (4) + " " (1) + "handling" (8) + " " (1) + "with" (4) + " " (1) + "depth" (5) + " " (1) + "limit" (5) =
3+1+9+1+4+1+4+1+8+1+4+1+5+1+5 = 48 chars. Good.

Actually, looking more carefully at the code:
- The depth check is added
- Recursion is added
- clearPendingActions is moved
- The CSS change is there

Is the body useful? Yes, it explains the behavioral change and why. But maybe I can keep it shorter.

Actually, looking at the instructions again: "If you can accurately express the change in just the subject line, don't
include anything in the message body."

Hmm. This is a fairly complex change. But the subject line "Add recursive tool call handling with depth limit" does
accurately express the main change. The CSS change is minor. However, there are enough details (the depth limit of 5,
the recursion aspect, the fact that it handles follow-up tool calls) that a brief body might be useful to someone
looking at the commit log.

But the instructions also say "Only use the body when it is providing *useful* information" and "Don't repeat
information from the subject line in the message body."

If I use the subject line I proposed, what useful info could the body add?
- The depth limit value (5)
- That it affects follow-up LLM responses
- The CSS fix

Actually, I think a body is appropriate here because the change is not entirely captured by just knowing it's
"recursive" - the fact that it previously dropped tool calls on follow-up is useful context (the "why").

Let me draft:

Add recursive tool call handling with depth limit

When follow-up LLM responses contain additional tool_calls, recurse in
processToolCalls instead of ignoring them. Add MAX_TOOL_CALL_DEPTH
constant set to 5 to prevent infinite loops. Move clearPendingActions
to appropriate recursion boundaries. Fix new-chat container flex layout
to wrap and align left.

Wait, I should check line wrapping. Max 72 chars.

Line 1: "Add recursive tool call handling with depth limit"
Line 2: (blank)
Line 3: "When follow-up LLM responses contain additional tool_calls, recurse in"
Line 4: "processToolCalls instead of ignoring them. Add MAX_TOOL_CALL_DEPTH"
Line 5: "constant set to 5 to prevent infinite loops. Move clearPendingActions"
Line 6: "to appropriate recursion boundaries. Fix new-chat container flex layout"
Line 7: "to wrap and align left."

Let me check lengths:
Line 3: "When follow-up LLM responses contain additional tool_calls, recurse in" = 70 chars. Good.
Line 4: "processToolCalls instead of ignoring them. Add MAX_TOOL_CALL_DEPTH" = 67 chars. Good.
Line 5: "constant set to 5 to prevent infinite loops. Move clearPendingActions" = 69 chars. Good.
Line 6: "to appropriate recursion boundaries. Fix new-chat container flex layout" = 69 chars. Good.
Line 7: "to wrap and align left." = 23 chars. Good.

Actually, should I mention processToolCalls specifically? It's a detail but useful.

Alternative body:
Support multi-turn tool calling by recursing when follow-up responses
contain additional tool_calls. Previously these were discarded and the
text response was shown instead. Add MAX_TOOL_CALL_DEPTH constant set
to 5. Also fix new-chat container flex layout.

Length check:
"Support multi-turn tool calling by recursing when follow-up responses" = 67
"contain additional tool_calls. Previously these were discarded and the" = 70
"text response was shown instead. Add MAX_TOOL_CALL_DEPTH constant set" = 69
"to 5. Also fix new-chat container flex layout." = 46

I think this is clearer. But does it repeat the subject? The subject says "Add recursive tool call handling" and the
body says "Support multi-turn tool calling by recursing..." - that's similar but explains the mechanism, not repeating
per se.

Actually, I could also make the subject slightly different to be more specific:
"Add multi-turn tool call recursion with depth limit"

Then body:
When follow-up LLM responses contain additional tool_calls, recurse in
processToolCalls rather than discarding them. Add MAX_TOOL_CALL_DEPTH
constant set to 5. Move clearPendingActions to correct boundaries.
Also fix new-chat container flex layout.

Hmm. "Add multi-turn tool call recursion with depth limit" is 49 chars. Good.

Actually, looking at the diff again, the most important thing is that `processToolCalls` now recurses. Let me make sure
I capture that.

Another option: The commit could be split conceptually, but since it's one diff, we summarize all changes.

Final decision:

Subject: Add recursive tool call handling with depth limit

Body:
When follow-up LLM responses contain additional tool_calls, recurse in
processToolCalls instead of ignoring them. Add MAX_TOOL_CALL_DEPTH
constant set to 5 to prevent infinite loops. Move clearPendingActions
to appropriate recursion boundaries. Fix new-chat container flex layout
to wrap and align left.

Wait, I want to make sure I don't exceed 72 chars per line in the body.

Line 1: When follow-up LLM responses contain additional tool_calls, recurse in
Count: W(1)h(2)e(3)n(4) (5)f(6)o(7)l(8)l(9)o(10)w(11)-(12)u(13)p(14) (15)L(16)L(17)M(18)
(19)r(20)e(21)s(22)p(23)o(24)n(25)s(26)e(27)s(28) (29)c(30)o(31)n(32)t(33)a(34)i(35)n(36)
(37)a(38)d(39)d(40)i(41)t(42)i(43)o(44)n(45)a(46)l(47) (48)t(49)o(50)o(51)l(52)_(53)c(54)a(55)l(56)l(57)s(58),(59)
(60)r(61)e(62)c(63)u(64)r(65)s(66)e(67) (68)i(69)n(70) = 70 chars. OK.

Line 2: processToolCalls instead of ignoring them. Add MAX_TOOL_CALL_DEPTH
p(1)r(2)o(3)c(4)e(5)s(6)s(7)T(8)o(9)o(10)l(11)C(12)a(13)l(14)l(15)s(16) (17)i(18)n(19)s(20)t(21)e(22)a(23)d(24)
(25)o(26)f(27) (28)i(29)g(30)n(31)o(32)r(33)i(34)n(35)g(36) (37)t(38)h(39)e(40)m(41).(42) (43)A(44)d(45)d(46)
(47)M(48)A(49)X(50)_(51)T(52)O(53)O(54)L(55)_(56)C(57)A(58)L(59)L(60)_(61)D(62)E(63)P(64)T(65)H(66) = 66 chars. OK.

Line 3: constant set to 5 to prevent infinite loops. Move clearPendingActions
c(1)o(2)n(3)s(4)t(5)a(6)n(7)t(8) (9)s(10)e(11)t(12) (13)t(14)o(15) (16)5(17) (18)t(19)o(20)
(21)p(22)r(23)e(24)v(25)e(26)n(27)t(28) (29)i(30)n(31)f(32)i(33)n(34)i(35)t(36)e(37) (38)l(39)o(40)o(41)p(42)s(43).(44)
(45)M(46)o(47)v(48)e(49)
(50)c(51)l(52)e(53)a(54)r(55)P(56)e(57)n(58)d(59)i(60)n(61)g(62)A(63)c(64)t(65)i(66)o(67)n(68)s(69) = 69 chars. OK.

Line 4: to appropriate recursion boundaries. Fix new-chat container flex layout
t(1)o(2) (3)a(4)p(5)p(6)r(7)o(8)p(9)r(10)i(11)a(12)t(13)e(14) (15)r(16)e(17)c(18)u(19)r(20)s(21)i(22)o(23)n(24)
(25)b(26)o(27)u(28)n(29)d(30)a(31)r(32)i(33)e(34)s(35).(36) (37)F(38)i(39)x(40)
(41)n(42)e(43)w(44)-(45)c(46)h(47)a(48)t(49) (50)c(51)o(52)n(53)t(54)a(55)i(56)n(57)e(58)r(59) (60)f(61)l(62)e(63)x(64)
(65)l(66)a(67)y(68)o(69)u(70)t(71) = 71 chars. OK.

Line 5: to wrap and align left.
t(1)o(2) (3)w(4)r(5)a(6)p(7) (8)a(9)n(10)d(11) (12)a(13)l(14)i(15)g(16)n(17) (18)l(19)e(20)f(21)t(22).(23) = 23 chars.
OK.

This looks good. But I should double check: does the body provide useful info not in the subject? Yes, it explains the
specific behavior change (recursing instead of ignoring), the constant name and value, and the CSS fix.

Actually, should I include the CSS fix in the body? It's part of the diff. If I omit it, the
2026-05-21 17:08:45 +02:00
fegger 624df32c37 Add model change callback and improve chat UI robustness
- Persist selected model to settings when changed via dropdown
- Reorder chat controls: model selector before mode selector, new chat before delete
- Add null guards for modelSelectorEl during async populate
- Fix streaming state and empty response handling in tool nudge loop
- Expand vault operation keyword detection
- Enable text selection in chat messages
- Include original error message in chat error display
2026-05-21 16:47:08 +02:00
fegger f6edc321e3 Add Ollama model selector dropdown and improve automatic tool detection
- Add `listModels()`, `setModel()`, and `getModel()` methods to `OllamaClient`
- Populate model dropdown in chat view from `/api/tags` endpoint
- Synchronize model selection across main and agent clients
- Detect user intent for vault operations from message content
- Expand action phrase matching for `shouldAutoRunReadTools()`
- Streamline retry logic and suppress "Let me..." text with actual tool calls
- Improve fallback search query generation by removing more filler words
2026-05-21 16:31:15 +02:00
fegger 51208e2031 Restructure chat input layout and fix activity indicator sizing
Move send button after activity indicator in DOM order and reposition
elements via CSS. Set textarea rows to 3, increase min/max heights,
add flex-shrink to activity dot to prevent squishing, and fix
activity indicator padding and white-space handling.
2026-05-21 15:18:49 +02:00
fegger 99a644ba96 Strengthen tool-calling discipline with retries and automatic fallback
Expand CRITICAL RULE instructions across edit, organize, and research agent
modes to explicitly prohibit planning language, reasoning, apologies, and
permission-seeking. Replace generic follow-up text with empty content when
tool calls are emitted.

Add a retry loop (up to 3 attempts) that suppresses text-only responses
and re-prompts the model to emit tool_calls immediately. If the model
still fails to call tools after retries, fall back to automatically
generated read tool calls based on the user's message intent.
2026-05-21 15:09:40 +02:00
fegger 949e7d77ff Add activity indicator for chat operations
Shows a pulsing dot with status text during workflow execution, LLM
thinking, and tool use. Disables the input and send button while active
to prevent duplicate submissions. Also restructures the chat layout to
use flexbox with the input container locked at the bottom.
2026-05-21 10:48:07 +02:00
fegger db96858222 Add automatic read tool execution for non-action assistant responses
Replace manual intent phrase matching with dedicated helpers that detect
when the assistant wants to use tools but didn't emit tool calls. When
detected, automatically build and execute read-only tool calls based on
the user's original message instead of nudging the model again.

This reduces back-and-forth latency for organize and research modes by
directly querying vault stats, tags, and files when the assistant
expresses intent like "let me read" or "I will check" but fails to
actually call tools.

Includes tests for the new auto-run behavior in organize mode.
2026-05-21 10:39:22 +02:00
fegger e7b753014c Add chat session history with dropdown UI
Implement persistent chat history using a new ChatHistoryManager class.
Sessions are saved on view close, restored on open, and selectable via a
dropdown in the chat header. Includes delete and clear-all commands,
with automatic title generation from the first user message.
2026-05-21 10:36:55 +02:00
fegger 5c2078aebb Strengthen tool-calling discipline and add auto-nudge fallback
Tighten system prompts across edit, organize, and research modes to
prohibit "Let me..." / "I will..." prefatory text and require immediate
tool_call emission. Remove stale instructions referencing deleted tools.
Improve search_vault_files description for LLM clarity. Add runtime
auto-nudge: if a tool-capable mode emits no tool_calls but language
suggests intent to act, re-prompt the model to emit the required calls.
2026-05-21 10:23:53 +02:00
fegger af9f0d165a Add list_vault_tags and get_vault_stats tools to all agent modes
Extend the tool registry to support vault-wide tag listing and statistical
overview tools, adding them to READ, EDIT, ORGANIZE, and RESEARCH agent
modes. Includes implementations for tag aggregation, folder structure
reporting, and metadataCache integration in VaultIndexer.
2026-05-21 10:08:05 +02:00
fegger 5350df92dd Add in-app debug log panel and rich vault search
- Add listener/history API to Logger for live log streaming
- Add toggle button and styled log panel to chat view
- Enhance ToolExecutor to use VaultIndexer for rich search results
- Instruct all agent modes to emit tool calls immediately instead of describing intent
2026-05-21 09:37:46 +02:00
fegger 68ac64cc02 Refactor conversation state to use dynamic system prompts
Move system prompt management from ChatView into ConversationStateManager,
ensuring the system prompt stays synchronized with the current agent mode.
Replace hardcoded default prompts with a shared constant and add setSystemPrompt
to support live updates when switching modes. Clean up minor formatting issues.
2026-05-21 09:09:55 +02:00
fegger 96f201bf3f Add dual-model support with separate chat and agent models
Split the single model setting into `chatModel` and `agentModel` to allow
using different LLMs for conversational modes (Ask, Research) versus
agentic modes (Edit, Organize, Workflow, auto-organizer). Defaults are
`deepseek-v4-flash` for chat and `glm-5.1` for agents.

Includes backward compatibility migration from legacy `model` field,
updated settings UI, per-mode tool filtering via new `agent-modes.ts`
configs, and vault search scoring improvements (exact phrase, recency,
filename bonuses).
2026-05-21 09:00:11 +02:00
fegger ae747a4470 Update README with new features and fix streaming state cleanup
Expand documentation for agent modes, workflows, auto-organizer, structured memory, tool telemetry, and enhanced context
sources. Set `isThinking` to false when finalizing assistant messages to ensure UI state resets correctly.
2026-05-20 23:25:07 +02:00
fegger 3d3b996839 Refine Ollama response content-type validation
Allow application/json and missing content-type headers, only rejecting
obvious non-JSON responses like HTML error pages. This improves
compatibility with different Ollama server configurations.
2026-05-20 22:57:43 +02:00
fegger f1afba70ff Add structured memory injection and telemetry tracking
- Introduce buildMessagesWithMemory() to prepend memory context as a system
  message before LLM calls
- Record LLM call telemetry (tokens, duration) for follow-up requests in
  both streaming and non-streaming paths
- Add telemetry coverage for tool execution (success/failure, args,
  duration)
- Update tool-executor tests to verify telemetry integration with
  TelemetryManager
2026-05-20 22:46:42 +02:00
fegger f98afcd6b0 Add tool telemetry tracking for LLM calls and vault searches
Introduces TelemetryManager to record tool calls, LLM token usage
(prompt_eval_count, eval_count), and vault search queries with timing.
Wires telemetry through ChatView, ToolExecutor, and OllamaClient with
configurable limits and enable/disable toggle.
2026-05-20 22:32:15 +02:00
fegger fbb744ba6b Add structured memory system for persistent agent context
Implements `StructuredMemoryManager` to track conversation summaries,
user preferences, and learned facts across sessions. Includes:
- Configurable storage limits with automatic enforcement
- Heuristic extraction of preferences and facts from messages
- Memory context injection into system prompts
- Full test coverage for all manager operations
2026-05-20 22:18:33 +02:00
fegger 9d4eb9a62a Add dry-run mode, target folder filtering, and tag normalization
- Add `dryRun` option to preview proposed tag/link changes without applying
- Add `targetFolder` config to restrict auto-tagging/linking to specific paths
- Add `normalizeTags` with vocabulary building to canonicalize generated tags against existing vault tags
- Update settings UI with new toggles and text inputs for both auto-tag and auto-link sections
2026-05-20 22:04:05 +02:00
fegger 2db7c34920 Add configurable agent modes with per-mode tool filtering
Introduces `ask`, `edit`, `organize`, `research`, and `workflow` modes.
Each mode defines its own system prompt, allowed tools, and preview
requirements. The active mode can be switched via a dropdown in the
chat UI and defaults can be set in plugin settings. Mode selection
affects which tools are exposed to the LLM and whether write actions
require user preview before execution.
2026-05-20 21:49:41 +02:00
fegger 2f739c6f21 Refactor test mocks to access engine instances directly
Update mock retrieval to use actual instances from WorkflowEngine instead of
mocking class constructors. Flatten search result fixtures to match updated
VaultIndexer return shape.
2026-05-20 20:52:23 +02:00
fegger 106abfa718 Integrate Obsidian metadataCache across extraction and tooling
Replaces regex-based parsing of frontmatter and headings with
Obsidian's metadataCache where available, falling back to regex
when the cache is unavailable. Propagates App dependency through
constructors to enable cache access.

Key changes:
- ContentExtractor accepts optional cache for frontmatter/headings
- ToolExecutor uses cache for section replacement and frontmatter
- NoteContextBuilder resolves titles/tags from cache
- VaultVectorStore passes cache through indexing pipeline
- AutoTagger checks cache for existing tags instead of content
- Mock updated with metadataCache stubs for tests
2026-05-20 20:36:47 +02:00
fegger 3c7c4d58bb Add hybrid search with filtering and recency boost
Introduces SearchOptions interface with folder/tag filters, exact phrase
matching via quoted queries, and optional recency boosting with configurable
half-life. Replaces pure semantic or keyword search with a combined scoring
model: keyword scores are blended with semantic results, then adjusted by
filters and recency. Adds mtime to vault index entries and expands test
coverage for the new options.
2026-05-20 19:52:49 +02:00
fegger 3abacb5d6e Add NoteContextBuilder for enhanced chat context gathering
Introduces NoteContextBuilder to extract explicit wikilink mentions,
detect scope intent, and gather contextual note data including
backlinks, outlinks, and related notes. Integrates into ChatView
and adds comprehensive unit tests.

Also includes minor type fixes: removes unnecessary `as` cast in
action-preview-builder, fixes non-null assertion in tool-executor,
and cleans up unused import in auto-organizer. Simplifies auto-tag
command callback by removing redundant async/await.
2026-05-20 19:17:42 +02:00
fegger 64ee7763fe Add workflow command support to chat view
Integrate WorkflowEngine into ChatView to handle `/workflow` commands.
When a message starts with `/workflow`, the workflow engine generates
and executes a multi-step plan instead of the standard chat flow.
Results are formatted with step-by-step status and output display.

Includes workflow result formatting, assistant message updates, and
short-term context tracking. Tests verify command parsing and engine
invocation.
2026-05-20 18:58:21 +02:00
fegger 158d5f68e6 Add action preview builder for write tool confirmation
Introduces `ActionPreviewBuilder` to generate before/after previews for
destructive operations. Write tools are now deferred with apply/cancel
UI instead of executing immediately. Includes `ProposedAction` type,
CSS for diff views, and state management in `ChatView`.
2026-05-20 18:36:00 +02:00
fegger 95a6954b50 Add vault note management tools
Expand the tool executor with create_note, append_to_note, replace_note_section,
update_frontmatter, rename_note, move_note, delete_note, and insert_link tools.
Rename create_file to create_note for consistency and add helper methods for
common file operations. Update tests to cover the new tools and renamed
functionality.
2026-05-20 18:19:09 +02:00
fegger 8e338afeac feat: increase vault context limits and make them configurable
- Replace hardcoded 2000-char context limit with configurable maxContextLength
  setting (default: 8000 characters)
- Increase vaultSearchLimit default from 3 to 5 notes
- Add 'Max Context Length' setting to plugin settings UI
- Update README with new defaults
- Update chat-view tests for new setting
2026-05-20 01:20:49 +02:00
fegger f09e134948 fix: include tags in vault context sent to AI and update system prompt
- chat-view.ts: include 'Tags: ...' prefix in vault context when tags exist
- chat-view.ts: update system prompt to instruct AI to pay attention to tags
  when organizing or categorizing notes
2026-05-20 01:13:40 +02:00
fegger a8d8936b11 feat: add auto-tag and auto-link note organization features
- Add AutoTagger: scans untagged notes, generates AI tags via Ollama,
  and applies them to frontmatter
- Add AutoLinker: finds semantically related notes via vault search
  and appends a 'Related Notes' section with wiki-links
- Add settings UI for both features with configurable thresholds,
  prompt templates, and limits
- Add commands: 'Auto-Tag Untagged Notes' and 'Auto-Link Related Notes'
- Add auto-organizer.test.ts with 17 tests covering tagging,
  linking, frontmatter manipulation, and filtering
2026-05-20 00:12:30 +02:00
fegger cb621c83b5 fix: process embeddings sequentially, shorten prompts, fix tests
- main.ts: process files sequentially (not Promise.all) to avoid concurrent
  embedding requests hammering Ollama; batch size reduced to 1
- vectorization.ts: shorten embedding prompts from 1000 to 500 chars,
  limit headings to 5, remove frontmatter from prompt to stay well within
  embedding model context window
- Update vectorization and indexing-pipeline tests for new prompt format
2026-05-19 23:51:29 +02:00
fegger cc97d77810 fix: use collection deletion instead of reset, add embedding retry, slow down indexing
- semantic-cache.clearCache: delete collection instead of client.reset()
  to avoid 403 Forbidden on newer ChromaDB versions
- vault-vector-store.clearIndex: same collection deletion approach
- ContentVectorizer: add 3-attempt retry with exponential backoff (1s, 2s, 4s)
- main.ts: reduce indexing batch size from 5 to 2, increase delay from 100ms to 500ms
- Update semantic-cache tests for deleteCollection
2026-05-19 23:41:05 +02:00
fegger ce109cf6fb fix: prevent race conditions during vault index rebuild and rate-limit embeddings
- Add AbortController to cancel ongoing indexing before clear/rebuild
- Track currentIndexingPromise to await cancellation before clearing collection
- Batch background indexing (5 files at a time) with 100ms delays between batches
- Skip incremental event-based indexing while a full rebuild is in progress
- Cancel indexing on plugin unload
- Fix nginx CORS headers on OPTIONS preflight responses
2026-05-19 23:29:34 +02:00
fegger fae74ade95 feat: add semantic/RAG vault indexing with automatic background sync
- Add VaultVectorStore backed by ChromaDB for vector-based vault search
- Integrate existing ContentVectorizer/IndexingPipeline for embeddings
- Update VaultIndexer to prefer semantic search with keyword fallback
- Background indexing on plugin load + incremental sync via vault events
- Add vault index settings, commands, and UI controls
- Add tests for VaultVectorStore
- Update README with RAG setup instructions
2026-05-19 23:00:04 +02:00
fegger 4f3472a49c fix: move all CORS headers into nginx location block
Server-level add_header does not reliably attach to proxied upstream
responses. Moving all CORS directives inside location / ensures they
are present on every response ChromaDB sends back, including 4xx/5xx.

Also removes the redundant CHROMA_SERVER_CORS_ALLOW_ORIGINS from the
chroma service so only Nginx manages CORS, avoiding conflicting
duplicate headers.
2026-05-19 22:21:44 +02:00
fegger b62ad15be8 fix: move CORS OPTIONS handling into nginx location block
Nginx does not allow add_header inside if blocks at the server level.
Moving the OPTIONS preflight response into the location / block fixes the
emerg directive error and allows nginx to start correctly.
2026-05-19 22:15:04 +02:00
fegger 76887ea8ca feat: add Nginx reverse proxy for robust CORS handling
Replace direct host port mapping with an Nginx reverse proxy that properly
handles CORS for any origin, including Obsidian's app://obsidian.md.

- docker-compose.yml: Add nginx service on port 8666, route to internal
  chroma:8000. Remove CORS env var from ChromaDB (handled by proxy now).

- nginx.conf (new): Minimal alpine config with permissive CORS headers,
  preflight OPTIONS handling, and streaming support (proxy_buffering off).

This fixes cross-origin access from remote Obsidian clients connecting over
Tailscale or VPN.
2026-05-19 22:08:11 +02:00
fegger 1ccd637149 fix: sanitize ChromaDB URL and update docker-compose CORS config
- src/semantic-cache.ts: Add robust URL sanitization in initialize().
  Trim whitespace and reject malformed URLs (e.g. empty host like
  'http://:8666') before passing to ChromaClient.

- src/main.ts: Validate ChromaDB URL in the settings tab onChange.
  Fall back to 'http://localhost:8000' if the value is empty or lacks
  '://'.

- docker-compose.yml: Add CHROMA_SERVER_CORS_ALLOW_ORIGINS=["*"] to
  allow cross-origin requests from Obsidian's app://obsidian.md origin.
2026-05-19 21:59:35 +02:00
fegger 6c438f7a4d fix: remove artificial MAX_STREAM_CHUNKS limit that cut off long responses
The plugin hard-capped streaming responses at 1000 chunks. For large
models like qwen2.5:32b generating detailed answers, this limit was
easily exceeded, causing the response to stop mid-sentence.

The Ollama stream already terminates naturally when the model sends the
final done signal, so the artificial chunk limit served no purpose.

- src/chat-view.ts: Remove chunkCount tracking and MAX_STREAM_CHUNKS
  constant. Let the stream run until Ollama signals completion.
2026-05-19 21:24:38 +02:00
fegger 70bf963f28 feat: add thinking indicator while model is generating
- src/types.ts: Add isThinking flag to ChatMessage to track transient
  'model is working' state.

- src/chat-view.ts: Set isThinking: true on the assistant placeholder
  message when user sends input. Clear it when the first stream chunk
  arrives or on error. Update render() to show a spinner + 'Thinking…'
  text while isThinking is active.

- styles.css: Add ollama-thinking-indicator class with a CSS spinner
  animation and muted italic text styling.
2026-05-19 21:21:47 +02:00
fegger 810676ff21 feat: add Ollama icon and modern chat UI styling
- src/chat-view.ts: Add getIcon() returning 'bot' for the view tab icon.
  Improve render() with role-specific CSS classes (user vs assistant) and
  message header structure for better styling hooks.

- src/main.ts: Add ribbon icon ('bot') in the left sidebar that opens the
  chat view with a single click.

- styles.css (new): Modern chat UI with message bubbles, distinct user and
  assistant themes using Obsidian CSS variables, sticky input bar, styled
  send button with accent color, and emoji role indicators.

- install.sh: Copy styles.css into the plugin directory and verify its
  presence during installation.

- README.md: Include styles.css in manual install instructions.

- __mocks__/obsidian.ts: Add addRibbonIcon() mock for test compatibility.

- tests/chat-view.test.ts: Add getIcon() assertion.
2026-05-19 21:18:56 +02:00
fegger 138890b9d2 feat: improve UX for Ollama 404 errors (missing model)
- src/ollama-client.ts: Detect HTTP 404 on /api/chat and throw a descriptive
  ApiError with the model name and the exact ollama pull command needed.

- src/error-handler.ts: For API_ERROR type, return the error message directly
  instead of prefixing with 'API error: ', so the user-friendly 404 message
  is shown cleanly in the Obsidian notice.

- tests/ollama-client.test.ts: Update 404 assertions to match the new
  descriptive error message.
2026-05-19 21:13:08 +02:00
fegger 1ed2e39c3d fix: resolve ESLint errors and align chromadb types with bundled client
- src/semantic-cache.ts: Replace require('chromadb') with static import and
  use proper ChromaClient/Collection types instead of any. Fix camelCase
  API parameters (queryEmbeddings, nResults) and wrap single embedding into
  Embedding[] for upsert. Fix clearCache to call client.reset() instead of
  collection.reset() (matches actual chromadb API).

- src/workflow-engine/workflow-engine.ts: Fix unnecessary escapes in regex,
  remove redundant 'as unknown' assertion, handle never type in template
  literal, and add type annotations to replace callback to satisfy
  no-unsafe-argument and no-base-to-string rules.

- tests/semantic-cache.test.ts: Update mocks to include client.reset() and
  adjust clearCache assertions to match new implementation.
2026-05-19 20:44:27 +02:00
fegger 97cc4ed5fe fix: bundle chromadb and fix fetch invocation in Electron
- Bundle chromadb into main.js via esbuild instead of externalizing it.
  Obsidian's renderer cannot resolve bare require('chromadb') against a
  plugin-local node_modules. By bundling, the client library is inlined and
  works out of the box with just manifest.json + main.js.

- Remove eager cache initialization from OllamaClient constructor to avoid
  unhandled promise rejections when chromadb/ChromaDB is unavailable.

- Fix 'Failed to execute fetch on Window: Illegal invocation' by wrapping
  the default fetch fallback in an arrow function:
    this.fetchFn = fetchFn ?? ((url, init) => fetch(url, init));
  This preserves the window binding when fetch is called later.

- Update install.sh and README to remove the obsolete node_modules/chromadb
  copy step.

- Update ollama-client-cache tests to reflect that cache initialization is
  no longer eager.
2026-05-19 20:38:50 +02:00
fegger 26e178fa96 Fix Electron resolution for optional chromadb dependency
Replace dynamic ESM import with require() for chromadb to ensure
Electron can resolve the package against the plugin's node_modules.
Also wrap default fetch fallback in an arrow function to avoid
potential strict mode issues with global fetch.
2026-05-19 20:19:28 +02:00
fegger 3342d7d955 fix: use npm install (not --production) so build tools are available
The install script ran npm install --production, which skips devDependencies.
Since typescript, esbuild, and other build tools live in devDependencies,
npm run build would fail with 'tsc: command not found'.

Switched to plain npm install so all dependencies are available at build
time.  Only the necessary runtime dep (chromadb) is copied to the plugin
folder — obsidian stays out since it's a type stub.
2026-05-19 19:01:45 +02:00
fegger 9367811c5a fix: switch to esbuild bundling — single main.js at plugin root
The root cause: Obsidian's plugin loader expects main.js at the plugin root
alongside manifest.json.  The previous 'dist/' output + shim approach caused
'Cannot find module ./dist/main.js' because dist/ was either missing or not
resolved correctly in Obsidian's module loader.

Changes:
- build: use esbuild to bundle all source into a single main.js (61KB)
  tsc --noEmit for type checking; esbuild for the actual bundle
- main.js: no longer a shim — it's the fully bundled plugin
- package.json: added esbuild as devDependency; obsidian moved to devDeps
- install.sh: remove dist/ copy step, add cleanup of old dist/ from vault
- README.md: updated manual install steps to reflect bundling
2026-05-19 18:23:24 +02:00
fegger 4ea2734ade fix: move obsidian to devDependencies, improve shim export
- package.json: move obsidian from dependencies to devDependencies
  (it is a type stub — having it in dependencies risks shadowing
  Obsidian's built-in API if node_modules is present in the plugin folder)
- main.js: explicitly extract default export from dist/main.js so the
  shim works with both plugin.default and direct-export loader patterns
2026-05-19 18:21:17 +02:00
fegger 44cff07ea1 fix: add main.js shim at plugin root for Obsidian loader
Obsidian's plugin loader expects main.js at the plugin root alongside
manifest.json — it does not resolve subdirectory paths in the 'main'
field.  Added a thin CommonJS shim that re-exports from dist/main.js.

- main.js: new entry shim (module.exports = require('./dist/main.js'))
- manifest.json: 'main' changed from 'dist/main.js' to 'main.js'
- install.sh: copy main.js shim to plugin folder, verify its presence
- README.md: add main.js to manual install instructions
2026-05-19 18:12:14 +02:00
fegger d31c989423 fix: cherry-pick improvements from feature/semantic_caching
- semantic-cache.ts: use upsert instead of add to avoid duplicate-ID errors
- semantic-cache.ts: add crypto.randomUUID() fallback for constrained runtimes
- ollama-client.ts: JSON.stringify non-string Ollama errors to avoid [object Object]
- tests: update semantic-cache mocks and assertions for upsert
2026-05-19 18:02:23 +02:00
50 changed files with 26150 additions and 563 deletions
+168 -17
View File
@@ -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 (01) 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 (01) 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 (01) 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
+24
View File
@@ -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');
}
}
+24
View File
@@ -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
View File
@@ -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
+14857
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
"author": "Anonymous",
"authorUrl": "",
"isDesktopOnly": true,
"main": "dist/main.js",
"main": "main.js",
"authorization": [],
"permissions": [],
"defaultEnabled": true,
+40
View File
@@ -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;
}
}
}
+498 -94
View File
@@ -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
View File
@@ -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"
}
}
+397
View File
@@ -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`;
}
}
+187
View File
@@ -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);
}
+493
View File
@@ -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 };
}
}
+108
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+41 -2
View File
@@ -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
View File
@@ -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,
},
];
}
+1 -1
View File
@@ -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:
+57 -28
View File
@@ -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
+53 -29
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+377
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+305
View File
@@ -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
View File
@@ -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` };
}
}
+121
View File
@@ -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
View File
@@ -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;
}
// ============================================================
+102
View File
@@ -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 = [];
}
}
+58
View File
@@ -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
View File
@@ -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;
+251
View File
@@ -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');
}
}
+24 -9
View File
@@ -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
View File
@@ -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;
}
+321
View File
@@ -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('');
});
});
});
+167
View File
@@ -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();
}
});
});
});
+403
View File
@@ -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
View File
@@ -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();
});
+20 -22
View File
@@ -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');
});
});
});
+5 -5
View File
@@ -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);
+200
View File
@@ -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]');
});
});
});
+9 -7
View File
@@ -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);
+5 -5
View File
@@ -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.');
});
});
+22 -6
View File
@@ -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,
});
});
});
});
+605
View File
@@ -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
View File
@@ -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);
});
});
});
+312
View File
@@ -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
View File
@@ -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');
});
});
});
+222
View File
@@ -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);
});
});
});
+3 -5
View File
@@ -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', () => {
+21 -24
View File
@@ -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,