Compare commits

...

10 Commits

Author SHA1 Message Date
Elian Doran
4d169809bd chore(llm): improve render notes skill 2026-03-31 18:12:42 +03:00
Elian Doran
2929d64fa0 chore(llm): improve TSX import skill 2026-03-31 18:07:28 +03:00
Elian Doran
20311d31f6 chore(llm): modify frontend script to prefer Preact 2026-03-31 16:04:48 +03:00
Elian Doran
c13b68ef42 feat(llm): basic skill to write scripts 2026-03-31 16:01:20 +03:00
Elian Doran
8eff623b67 Merge remote-tracking branch 'origin/main' into feature/llm_tools 2026-03-31 15:52:10 +03:00
Elian Doran
f4b9207379 fix(llm/sidebar): no longer properly persisting the chat 2026-03-31 15:52:05 +03:00
Elian Doran
90930e19e7 feat(llm): improve search discoverability 2026-03-31 15:41:56 +03:00
Elian Doran
8c0dacd6d7 feat(llm): basic skill to do search 2026-03-31 15:36:50 +03:00
Elian Doran
c617bea45a feat(llm): basic tool to get subtree 2026-03-31 15:15:14 +03:00
Elian Doran
bac25c9173 feat(llm): basic tool to get child notes 2026-03-31 15:04:02 +03:00
10 changed files with 714 additions and 75 deletions

View File

@@ -104,7 +104,7 @@ export interface SavedData {
export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
noteType: NoteType;
note: FNote,
note: FNote | null | undefined,
noteContext: NoteContext | null | undefined,
getData: () => Promise<SavedData | undefined> | SavedData | undefined,
onContentChange: (newContent: string) => void,
@@ -118,8 +118,8 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
return async () => {
const data = await getData();
// for read only notes
if (data === undefined || note.type !== noteType) return;
// for read only notes, or if note is not yet available (e.g. lazy creation)
if (data === undefined || !note || note.type !== noteType) return;
protected_session_holder.touchProtectedSessionIfNecessary(note);
@@ -138,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
// React to note/blob changes.
useEffect(() => {
if (!blob) return;
if (!blob || !note) return;
noteSavedDataStore.set(note.noteId, blob.content);
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
}, [ blob ]);

View File

@@ -10,7 +10,7 @@ import { formatDateTime } from "../../utils/formatters";
import ActionButton from "../react/ActionButton.js";
import Dropdown from "../react/Dropdown.js";
import { FormListItem } from "../react/FormList.js";
import { useActiveNoteContext, useNote, useNoteProperty } from "../react/hooks.js";
import { useActiveNoteContext, useEditorSpacedUpdate, useNote, useNoteProperty } from "../react/hooks.js";
import NoItems from "../react/NoItems.js";
import ChatInputBar from "../type_widgets/llm_chat/ChatInputBar.js";
import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js";
@@ -25,9 +25,8 @@ import RightPanelWidget from "./RightPanelWidget.js";
*/
export default function SidebarChat() {
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [shouldSave, setShouldSave] = useState(false);
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
const historyDropdownRef = useRef<BootstrapDropdown | null>(null);
// Get the current active note context
@@ -40,10 +39,40 @@ export default function SidebarChat() {
// Use shared chat hook with sidebar-specific options
const chat = useLlmChat(
// onMessagesChange - trigger save
() => setShouldSave(true),
() => spacedUpdateRef.current?.scheduleUpdate(),
{ defaultEnableNoteTools: true, supportsExtendedThinking: true }
);
// Ref to access chat methods in callbacks without triggering re-runs
const chatRef = useRef(chat);
chatRef.current = chat;
// Persistence via useEditorSpacedUpdate (same mechanism as the LlmChat type widget).
// When chatNote is null (before lazy creation), saves are no-ops.
const spacedUpdate = useEditorSpacedUpdate({
note: chatNote,
noteType: "llmChat",
noteContext: null,
getData: () => {
const content = chatRef.current.getContent();
return { content: JSON.stringify(content) };
},
onContentChange: (content) => {
if (!content) {
chatRef.current.clearMessages();
return;
}
try {
const parsed: LlmChatContent = JSON.parse(content);
chatRef.current.loadFromContent(parsed);
} catch (e) {
console.error("Failed to parse LLM chat content:", e);
chatRef.current.clearMessages();
}
}
});
spacedUpdateRef.current = spacedUpdate;
// Update chat context when active note changes
useEffect(() => {
chat.setContextNoteId(activeNoteId ?? undefined);
@@ -54,42 +83,6 @@ export default function SidebarChat() {
chat.setChatNoteId(chatNoteId ?? undefined);
}, [chatNoteId, chat.setChatNoteId]);
// Ref to access chat methods in effects without triggering re-runs
const chatRef = useRef(chat);
chatRef.current = chat;
// Handle debounced save when shouldSave is triggered
useEffect(() => {
if (!shouldSave || !chatNoteId) {
setShouldSave(false);
return;
}
setShouldSave(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(async () => {
const content = chat.getContent();
try {
await server.put(`notes/${chatNoteId}/data`, {
content: JSON.stringify(content)
});
} catch (err) {
console.error("Failed to save chat:", err);
}
}, 500);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = undefined;
}
};
}, [shouldSave, chatNoteId, chat]);
// Load the most recent chat on mount (runs once)
useEffect(() => {
let cancelled = false;
@@ -102,16 +95,6 @@ export default function SidebarChat() {
if (existingChat) {
setChatNoteId(existingChat.noteId);
// Load content inline to avoid dependency issues
try {
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
if (!cancelled && blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load chat content:", err);
}
} else {
// No existing chat - will create on first message
setChatNoteId(null);
@@ -170,31 +153,38 @@ export default function SidebarChat() {
}, [handleSubmit]);
const handleNewChat = useCallback(async () => {
// Save any pending changes before switching
await spacedUpdate.updateNowIfNecessary();
try {
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
chat.clearMessages();
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to create new chat:", err);
}
}, [chat]);
}, [spacedUpdate]);
const handleSaveChat = useCallback(async () => {
if (!chatNoteId) return;
// Save any pending changes before moving the chat
await spacedUpdate.updateNowIfNecessary();
try {
await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId });
// Create a new empty chat after saving
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
chat.clearMessages();
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to save chat to permanent location:", err);
}
}, [chatNoteId, chat]);
}, [chatNoteId, spacedUpdate]);
const loadRecentChats = useCallback(async () => {
try {
@@ -210,17 +200,11 @@ export default function SidebarChat() {
if (noteId === chatNoteId) return;
try {
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
if (blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
setChatNoteId(noteId);
chat.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load selected chat:", err);
}
}, [chatNoteId, chat]);
// Save any pending changes before switching
await spacedUpdate.updateNowIfNecessary();
setChatNoteId(noteId);
}, [chatNoteId, spacedUpdate]);
return (
<RightPanelWidget

View File

@@ -3,7 +3,8 @@ import { generateText, streamText, stepCountIs, type CoreMessage, type ToolSet }
import type { LlmMessage } from "@triliumnext/commons";
import becca from "../../../becca/becca.js";
import { noteTools, attributeTools, currentNoteTools } from "../tools/index.js";
import { getSkillsSummary } from "../skills/index.js";
import { noteTools, attributeTools, hierarchyTools, skillTools, currentNoteTools } from "../tools/index.js";
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
const DEFAULT_MODEL = "claude-sonnet-4-6";
@@ -135,6 +136,14 @@ export class AnthropicProvider implements LlmProvider {
}
}
// Add skills hint so the LLM knows to load skills before complex operations
if (config.enableNoteTools) {
const skillsHint = `You have access to skills that provide specialized instructions. Load a skill with the load_skill tool before performing complex operations.\n\nAvailable skills:\n${getSkillsSummary()}`;
systemPrompt = systemPrompt
? `${systemPrompt}\n\n${skillsHint}`
: skillsHint;
}
// Convert to AI SDK message format with cache control breakpoints.
// The system prompt and conversation history (all but the last user message)
// are stable across turns, so we mark them for caching to reduce costs.
@@ -206,6 +215,8 @@ export class AnthropicProvider implements LlmProvider {
if (config.enableNoteTools) {
Object.assign(tools, noteTools);
Object.assign(tools, attributeTools);
Object.assign(tools, hierarchyTools);
Object.assign(tools, skillTools);
}
if (Object.keys(tools).length > 0) {

View File

@@ -0,0 +1,156 @@
# Trilium Backend Scripting
Backend scripts run in Node.js on the server. They have direct access to notes in memory and can interact with the system (files, processes).
## Creating a backend script
1. Create a Code note with language "JS backend".
2. The script can be run manually (Execute button) or triggered automatically.
## Script API (`api` global)
### Note retrieval
- `api.getNote(noteId)` - get note by ID
- `api.searchForNotes(query, searchParams)` - search notes (returns array)
- `api.searchForNote(query)` - search notes (returns first match)
- `api.getNotesWithLabel(name, value?)` - find notes by label
- `api.getNoteWithLabel(name, value?)` - find first note by label
- `api.getBranch(branchId)` - get branch by ID
- `api.getAttribute(attributeId)` - get attribute by ID
### Note creation
- `api.createTextNote(parentNoteId, title, content)` - create text note
- `api.createDataNote(parentNoteId, title, content)` - create JSON note
- `api.createNewNote({ parentNoteId, title, content, type })` - create note with full options
### Branch management
- `api.ensureNoteIsPresentInParent(noteId, parentNoteId, prefix?)` - create or reuse branch
- `api.ensureNoteIsAbsentFromParent(noteId, parentNoteId)` - remove branch if exists
- `api.toggleNoteInParent(present, noteId, parentNoteId, prefix?)` - toggle branch
### Calendar/date notes
- `api.getTodayNote()` - get/create today's day note
- `api.getDayNote(date)` - get/create day note (YYYY-MM-DD)
- `api.getWeekNote(date)` - get/create week note
- `api.getMonthNote(date)` - get/create month note (YYYY-MM)
- `api.getYearNote(year)` - get/create year note (YYYY)
### Utilities
- `api.log(message)` - log to Trilium logs and UI
- `api.randomString(length)` - generate random string
- `api.escapeHtml(string)` / `api.unescapeHtml(string)`
- `api.getInstanceName()` - get instance name
- `api.getAppInfo()` - get application info
### Libraries
- `api.axios` - HTTP client
- `api.dayjs` - date manipulation
- `api.xml2js` - XML parser
- `api.cheerio` - HTML/XML parser
### Advanced
- `api.transactional(func)` - wrap code in a database transaction
- `api.sql` - direct SQL access
- `api.sortNotes(parentNoteId, sortConfig)` - sort child notes
- `api.runOnFrontend(script, params)` - execute code on all connected frontends
- `api.backupNow(backupName)` - create a backup
- `api.exportSubtreeToZipFile(noteId, format, zipFilePath)` - export subtree (format: "markdown" or "html")
- `api.duplicateSubtree(origNoteId, newParentNoteId)` - clone note and children
## BNote object
Available on notes returned from API methods (`api.getNote()`, `api.originEntity`, etc.).
### Content
- `note.getContent()` / `note.setContent(content)`
- `note.getJsonContent()` / `note.setJsonContent(obj)`
- `note.getJsonContentSafely()` - returns null on parse error
### Properties
- `note.noteId`, `note.title`, `note.type`, `note.mime`
- `note.dateCreated`, `note.dateModified`
- `note.isProtected`, `note.isArchived`
### Hierarchy
- `note.getParentNotes()` / `note.getChildNotes()`
- `note.getParentBranches()` / `note.getChildBranches()`
- `note.hasChildren()`, `note.getAncestors()`
- `note.getSubtreeNoteIds()` - all descendant IDs
- `note.hasAncestor(ancestorNoteId)`
### Attributes (including inherited)
- `note.getLabels(name?)` / `note.getLabelValue(name)`
- `note.getRelations(name?)` / `note.getRelation(name)`
- `note.hasLabel(name, value?)` / `note.hasRelation(name, value?)`
### Attribute modification
- `note.setLabel(name, value?)` / `note.removeLabel(name, value?)`
- `note.setRelation(name, targetNoteId)` / `note.removeRelation(name, value?)`
- `note.addLabel(name, value?, isInheritable?)` / `note.addRelation(name, targetNoteId, isInheritable?)`
- `note.toggleLabel(enabled, name, value?)`
### Operations
- `note.save()` - persist changes
- `note.deleteNote()` - soft delete
- `note.cloneTo(parentNoteId)` - clone to another parent
### Type checks
- `note.isJson()`, `note.isJavaScript()`, `note.isHtml()`, `note.isImage()`
- `note.hasStringContent()` - true if not binary
## Events and triggers
### Global events (via `#run` label on the script note)
- `#run=backendStartup` - run when server starts
- `#run=hourly` - run once per hour (use `#runAtHour=N` to specify which hours)
- `#run=daily` - run once per day
### Entity events (via relation from the entity to the script note)
These are defined as relations. `api.originEntity` contains the entity that triggered the event.
| Relation | Trigger | originEntity |
|---|---|---|
| `~runOnNoteCreation` | note created | BNote |
| `~runOnChildNoteCreation` | child note created under this note | BNote (child) |
| `~runOnNoteTitleChange` | note title changed | BNote |
| `~runOnNoteContentChange` | note content changed | BNote |
| `~runOnNoteChange` | note metadata changed (not content) | BNote |
| `~runOnNoteDeletion` | note deleted | BNote |
| `~runOnBranchCreation` | branch created (clone/move) | BBranch |
| `~runOnBranchChange` | branch updated | BBranch |
| `~runOnBranchDeletion` | branch deleted | BBranch |
| `~runOnAttributeCreation` | attribute created on this note | BAttribute |
| `~runOnAttributeChange` | attribute changed/deleted on this note | BAttribute |
Relations can be inheritable — when set, they apply to all descendant notes.
## Example: auto-color notes by category
```javascript
// Attach via ~runOnAttributeChange relation
const attr = api.originEntity;
if (attr.name !== "mycategory") return;
const note = api.getNote(attr.noteId);
if (attr.value === "Health") {
note.setLabel("color", "green");
} else {
note.removeLabel("color");
}
```
## Example: create a daily summary
```javascript
// Attach #run=daily label
const today = api.getTodayNote();
const tasks = api.searchForNotes('#task #!completed');
let summary = "## Open Tasks\n";
for (const task of tasks) {
summary += `- ${task.title}\n`;
}
api.createTextNote(today.noteId, "Daily Summary", summary);
```
## Module system
Child notes of a script act as modules. Export with `module.exports = ...` and import via function parameters matching the child note title, or use `require('noteName')`.

View File

@@ -0,0 +1,240 @@
# Trilium Frontend Scripting
Frontend scripts run in the browser. They can manipulate the UI, navigate notes, show dialogs, and create custom widgets.
IMPORTANT: Always prefer Preact JSX widgets over legacy jQuery widgets. Use JSX code notes with `import`/`export` syntax.
CRITICAL: In JSX notes, always use top-level `import` statements (e.g. `import { useState } from "trilium:preact"`). NEVER use dynamic `await import()` for Preact imports — this will break hooks and components. Dynamic imports are not needed because JSX notes natively support ES module `import`/`export` syntax.
## Creating a frontend script
1. Create a Code note with language "JSX" (preferred) or "JS frontend" (legacy only).
2. Add `#widget` label for widgets, or `#run=frontendStartup` for auto-run scripts.
3. For mobile, use `#run=mobileStartup` instead.
## Script types
| Type | Language | Required attribute |
|---|---|---|
| Custom widget | JSX (preferred) | `#widget` |
| Regular script | JS frontend | `#run=frontendStartup` (optional) |
| Render note | JSX | None (used via `~renderNote` relation) |
## Custom widgets (Preact JSX) — preferred
### Basic widget
```jsx
import { defineWidget } from "trilium:preact";
import { useState } from "trilium:preact";
export default defineWidget({
parent: "center-pane",
position: 10,
render: () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Clicked {count} times
</button>
</div>
);
}
});
```
### Note context aware widget (reacts to active note)
```jsx
import { defineWidget, useNoteContext, useNoteProperty } from "trilium:preact";
export default defineWidget({
parent: "note-detail-pane",
position: 10,
render: () => {
const { note } = useNoteContext();
const title = useNoteProperty(note, "title");
return <span>Current note: {title}</span>;
}
});
```
### Right panel widget (sidebar)
```jsx
import { defineWidget, RightPanelWidget, useState, useEffect } from "trilium:preact";
export default defineWidget({
parent: "right-pane",
position: 1,
render() {
const [time, setTime] = useState();
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date().toLocaleString());
}, 1000);
return () => clearInterval(interval);
});
return (
<RightPanelWidget id="my-clock" title="Clock">
<p>The time is: {time}</p>
</RightPanelWidget>
);
}
});
```
### Widget locations (`parent` values)
| Value | Description | Notes |
|---|---|---|
| `left-pane` | Alongside the note tree | |
| `center-pane` | Content area, spanning all splits | |
| `note-detail-pane` | Inside a note, split-aware | Use `useNoteContext()` hook |
| `right-pane` | Right sidebar section | Wrap in `<RightPanelWidget>` |
### Preact imports
```jsx
// API methods
import { showMessage, showError, getNote, searchForNotes, activateNote,
runOnBackend, getActiveContextNote } from "trilium:api";
// Hooks and components
import { defineWidget, defineLauncherWidget,
useState, useEffect, useCallback, useMemo, useRef,
useNoteContext, useActiveNoteContext, useNoteProperty,
RightPanelWidget } from "trilium:preact";
// Built-in UI components
import { ActionButton, Button, LinkButton, Modal,
NoteAutocomplete, FormTextBox, FormToggle, FormCheckbox,
FormDropdownList, FormGroup, FormText, FormTextArea,
Icon, LoadingSpinner, Slider, Collapsible } from "trilium:preact";
```
### Custom hooks
- `useNoteContext()` - returns `{ note }` for the current note context (use in `note-detail-pane`)
- `useActiveNoteContext()` - returns `{ note, noteId }` for the active note (works from any widget location)
- `useNoteProperty(note, propName)` - reactively watches a note property (e.g. "title", "type")
### Render notes (JSX)
For rendering custom content inside a note:
1. Create a "render note" (type: Render Note) where you want the content to appear.
2. Create a JSX code note **as a child** of the render note, exporting a default component.
3. On the render note, add a `~renderNote` relation pointing to the child JSX note.
IMPORTANT: Always create the JSX code note as a child of the render note, not as a sibling or at the root. This keeps them organized together.
```jsx
export default function MyRenderNote() {
return (
<>
<h1>Custom rendered content</h1>
<p>This appears inside the note.</p>
</>
);
}
```
## Script API
In JSX, use `import { method } from "trilium:api"`. In JS frontend, use the `api` global.
### Navigation & tabs
- `activateNote(notePath)` - navigate to a note
- `activateNewNote(notePath)` - navigate and wait for sync
- `openTabWithNote(notePath, activate?)` - open in new tab
- `openSplitWithNote(notePath, activate?)` - open in new split
- `getActiveContextNote()` - get currently active note
- `getActiveContextNotePath()` - get path of active note
- `setHoistedNoteId(noteId)` - hoist/unhoist note
### Note access & search
- `getNote(noteId)` - get note by ID
- `getNotes(noteIds)` - bulk fetch notes
- `searchForNotes(searchString)` - search with full query syntax
- `searchForNote(searchString)` - search returning first result
### Calendar/date notes
- `getTodayNote()` - get/create today's note
- `getDayNote(date)` / `getWeekNote(date)` / `getMonthNote(month)` / `getYearNote(year)`
### Editor access
- `getActiveContextTextEditor()` - get CKEditor instance
- `getActiveContextCodeEditor()` - get CodeMirror instance
- `addTextToActiveContextEditor(text)` - insert text into active editor
### Dialogs & notifications
- `showMessage(msg)` - info toast
- `showError(msg)` - error toast
- `showConfirmDialog(msg)` - confirm dialog (returns boolean)
- `showPromptDialog(msg)` - prompt dialog (returns user input)
### Backend integration
- `runOnBackend(func, params)` - execute a function on the backend
### UI interaction
- `triggerCommand(name, data)` - trigger a command
- `bindGlobalShortcut(shortcut, handler, namespace?)` - add keyboard shortcut
### Utilities
- `formatDateISO(date)` - format as YYYY-MM-DD
- `randomString(length)` - generate random string
- `dayjs` - day.js library
- `log(message)` - log to script log pane
## FNote object
Available via `getNote()`, `getActiveContextNote()`, `useNoteContext()`, etc.
### Properties
- `note.noteId`, `note.title`, `note.type`, `note.mime`
- `note.isProtected`, `note.isArchived`
### Content
- `note.getContent()` - get note content
- `note.getJsonContent()` - parse content as JSON
### Hierarchy
- `note.getParentNotes()` / `note.getChildNotes()`
- `note.hasChildren()`, `note.getSubtreeNoteIds()`
### Attributes
- `note.getAttributes(type?, name?)` - all attributes (including inherited)
- `note.getOwnedAttributes(type?, name?)` - only owned attributes
- `note.hasAttribute(type, name)` - check for attribute
## Legacy jQuery widgets (avoid if possible)
Only use legacy widgets if you specifically need jQuery or cannot use JSX.
```javascript
// Language: JS frontend, Label: #widget
class MyWidget extends api.BasicWidget {
get position() { return 1; }
get parentWidget() { return "center-pane"; }
doRender() {
this.$widget = $("<div>");
this.$widget.append($("<button>Click me</button>")
.on("click", () => api.showMessage("Hello!")));
return this.$widget;
}
}
module.exports = new MyWidget();
```
Key differences from Preact:
- Use `api.` global instead of imports
- `get parentWidget()` instead of `parent` field
- `module.exports = new MyWidget()` (instance) for most widgets
- `module.exports = MyWidget` (class, no `new`) for `note-detail-pane`
- Right pane: extend `api.RightPanelWidget`, override `doRenderBody()` instead of `doRender()`
## Module system
For JSX, use `import`/`export` syntax between notes. For JS frontend, use `module.exports` and function parameters matching child note titles.

View File

@@ -0,0 +1,76 @@
/**
* LLM skills — on-demand instruction sets that an LLM can load when it needs
* specialized knowledge (e.g. search syntax). Only names and descriptions are
* included in the system prompt; full content is fetched via the load_skill tool.
*/
import { tool } from "ai";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
const __dirname = dirname(fileURLToPath(import.meta.url));
interface SkillDefinition {
name: string;
description: string;
file: string;
}
const SKILLS: SkillDefinition[] = [
{
name: "search_syntax",
description: "Trilium search query syntax reference — labels, relations, note properties, boolean logic, ordering, and more.",
file: "search_syntax.md"
},
{
name: "backend_scripting",
description: "Backend (Node.js) scripting API — creating notes, handling events, accessing entities, database operations, and automation.",
file: "backend_scripting.md"
},
{
name: "frontend_scripting",
description: "Frontend (browser) scripting API — UI widgets, navigation, dialogs, editor access, Preact/JSX components, and keyboard shortcuts.",
file: "frontend_scripting.md"
}
];
function loadSkillContent(name: string): string | null {
const skill = SKILLS.find((s) => s.name === name);
if (!skill) {
return null;
}
return readFileSync(join(__dirname, skill.file), "utf-8");
}
/**
* Returns a summary of available skills for inclusion in the system prompt.
*/
export function getSkillsSummary(): string {
return SKILLS
.map((s) => `- **${s.name}**: ${s.description}`)
.join("\n");
}
/**
* The load_skill tool — lets the LLM fetch full instructions on demand.
*/
export const loadSkill = tool({
description: "Load a skill to get specialized instructions. Available skills:\n"
+ SKILLS.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
inputSchema: z.object({
name: z.string().describe("The skill name to load")
}),
execute: async ({ name }) => {
const content = loadSkillContent(name);
if (!content) {
return { error: `Unknown skill: '${name}'. Available: ${SKILLS.map((s) => s.name).join(", ")}` };
}
return { skill: name, instructions: content };
}
});
export const skillTools = {
load_skill: loadSkill
};

View File

@@ -0,0 +1,50 @@
# Trilium Search Syntax
## Full-text search
- `rings tolkien` — notes containing both words
- `"The Lord of the Rings"` — exact phrase match
## Label filters
- `#book` — notes with the "book" label
- `#!book` — notes WITHOUT the "book" label
- `#publicationYear = 1954` — exact value
- `#genre *=* fan` — contains substring
- `#title =* The` — starts with
- `#title *= Rings` — ends with
- `#publicationYear >= 1950` — numeric comparison (>, >=, <, <=)
- `#dateNote >= TODAY-30` — date keywords: NOW+-seconds, TODAY+-days, MONTH+-months, YEAR+-years
- `#phone %= '\d{3}-\d{4}'` — regex match
- `#title ~= trilim` — fuzzy exact match (tolerates typos, min 3 chars)
- `#content ~* progra` — fuzzy contains match
## Relation filters
- `~author` — notes with an "author" relation
- `~author.title *=* Tolkien` — relation target's title contains "Tolkien"
- `~author.relations.son.title = 'Christopher Tolkien'` — deep relation traversal
## Note properties
Access via `note.` prefix: noteId, title, type, mime, text, content, rawContent, dateCreated, dateModified, isProtected, isArchived, parentCount, childrenCount, attributeCount, labelCount, relationCount, contentSize, revisionCount.
- `note.type = code AND note.mime = 'application/json'`
- `note.content *=* searchTerm`
## Hierarchy
- `note.parents.title = 'Books'` — parent named "Books"
- `note.ancestors.title = 'Books'` — any ancestor named "Books"
- `note.children.title = 'sub-note'` — child named "sub-note"
## Boolean logic
- AND: `#book AND #fantasy` (implicit between adjacent expressions)
- OR: `#book OR #author`
- NOT: `not(note.ancestors.title = 'Tolkien')`
- Parentheses: `(#genre = "fantasy" AND #year >= 1950) OR #award`
## Combining full-text and attributes
- `towers #book` — full-text "towers" AND has #book label
- `tolkien #book or #author` — full-text with OR on labels
## Ordering and limiting
- `#author=Tolkien orderBy #publicationDate desc, note.title limit 10`
## Escaping
- `\#hash` — literal # in full-text
- Three quote types: single, double, backtick

View File

@@ -0,0 +1,102 @@
/**
* LLM tools for navigating the note hierarchy (tree structure, branches).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import type BNote from "../../../becca/entities/bnote.js";
/**
* Get the child notes of a given note.
*/
export const getChildNotes = tool({
description: "Get the immediate child notes of a note. Returns each child's ID, title, type, and whether it has children of its own. Use noteId 'root' to list top-level notes.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the parent note (use 'root' for top-level)")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return note.getChildNotes().map((child) => ({
noteId: child.noteId,
title: child.getTitleOrProtected(),
type: child.type,
childCount: child.getChildNotes().length
}));
}
});
//#region Subtree tool implementation
const MAX_DEPTH = 5;
const MAX_CHILDREN_PER_LEVEL = 10;
interface SubtreeNode {
noteId: string;
title: string;
type: string;
children?: SubtreeNode[] | string;
}
function buildSubtree(note: BNote, depth: number, maxDepth: number): SubtreeNode {
const node: SubtreeNode = {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
if (depth >= maxDepth) {
const childCount = note.getChildNotes().length;
if (childCount > 0) {
node.children = `${childCount} children not shown (depth limit reached)`;
}
return node;
}
const children = note.getChildNotes();
if (children.length === 0) {
return node;
}
const shown = children.slice(0, MAX_CHILDREN_PER_LEVEL);
node.children = shown.map((child) => buildSubtree(child, depth + 1, maxDepth));
if (children.length > MAX_CHILDREN_PER_LEVEL) {
node.children.push({
noteId: "",
title: `... and ${children.length - MAX_CHILDREN_PER_LEVEL} more`,
type: "truncated"
});
}
return node;
}
/**
* Get a subtree of notes up to a specified depth.
*/
export const getSubtree = tool({
description: "Get a nested subtree of notes starting from a given note, traversing multiple levels deep. Useful for understanding the structure of a section of the note tree. Each level shows up to 10 children.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the root note for the subtree (use 'root' for the entire tree)"),
depth: z.number().min(1).max(MAX_DEPTH).optional().describe(`How many levels deep to traverse (1-${MAX_DEPTH}). Defaults to 2.`)
}),
execute: async ({ noteId, depth = 2 }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return buildSubtree(note, 0, depth);
}
});
//#endregion
export const hierarchyTools = {
get_child_notes: getChildNotes,
get_subtree: getSubtree
};

View File

@@ -5,3 +5,5 @@
export { noteTools, currentNoteTools } from "./note_tools.js";
export { attributeTools } from "./attribute_tools.js";
export { hierarchyTools } from "./hierarchy_tools.js";
export { skillTools } from "../skills/index.js";

View File

@@ -43,15 +43,33 @@ function setNoteContentFromLlm(note: { type: string; title: string; setContent:
* Search for notes in the knowledge base.
*/
export const searchNotes = tool({
description: "Search for notes in the user's knowledge base. Returns note metadata including title, type, and IDs.",
description: [
"Search for notes in the user's knowledge base using Trilium search syntax.",
"For complex queries (boolean logic, relations, regex, ordering), load the 'search_syntax' skill first via load_skill.",
"Common patterns:",
"- Full-text: 'rings tolkien' (notes containing both words)",
"- By label: '#book', '#status = done', '#year >= 2000'",
"- By type: 'note.type = code'",
"- By relation: '~author', '~author.title *= Tolkien'",
"- Combined: 'tolkien #book' (full-text + label filter)",
"- Negation: '#!archived' (notes WITHOUT label)"
].join(" "),
inputSchema: z.object({
query: z.string().describe("Search query (supports Trilium search syntax)")
query: z.string().describe("Search query in Trilium search syntax"),
fastSearch: z.boolean().optional().describe("If true, skip content search (only titles and attributes). Faster for large databases."),
includeArchivedNotes: z.boolean().optional().describe("If true, include archived notes in results."),
ancestorNoteId: z.string().optional().describe("Limit search to a subtree rooted at this note ID."),
limit: z.number().optional().describe("Maximum number of results to return. Defaults to 10.")
}),
execute: async ({ query }) => {
const searchContext = new SearchContext({});
execute: async ({ query, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => {
const searchContext = new SearchContext({
fastSearch,
includeArchivedNotes,
ancestorNoteId
});
const results = searchService.findResultsWithQuery(query, searchContext);
return results.slice(0, 10).map(sr => {
return results.slice(0, limit).map(sr => {
const note = becca.notes[sr.noteId];
if (!note) return null;
return {