Feature/llm tools (#9241)

This commit is contained in:
Elian Doran
2026-03-31 22:10:16 +03:00
committed by GitHub
21 changed files with 1241 additions and 189 deletions

View File

@@ -155,6 +155,12 @@ Trilium provides powerful user scripting capabilities:
- Add migration scripts in `apps/server/src/migrations/`
- Update schema in `apps/server/src/assets/db/schema.sql`
### Server-Side Static Assets
- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/`
- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`)
- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory
- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree
## Build System Notes
- Uses pnpm for monorepo management
- Vite for fast development builds

View File

@@ -3,10 +3,10 @@ import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } fr
import server from "./server.js";
/**
* Fetch available models for a provider.
* Fetch available models from all configured providers.
*/
export async function getAvailableModels(provider: string = "anthropic"): Promise<LlmModelInfo[]> {
const response = await server.get<{ models?: LlmModelInfo[] }>(`llm-chat/models?provider=${encodeURIComponent(provider)}`);
export async function getAvailableModels(): Promise<LlmModelInfo[]> {
const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models");
return response.models ?? [];
}

View File

@@ -239,8 +239,10 @@ export function useLlmChat(
.join("")
}));
const selectedModelProvider = availableModels.find(m => m.id === selectedModel)?.provider;
const streamOptions: Parameters<typeof streamChatCompletion>[1] = {
model: selectedModel || undefined,
provider: selectedModelProvider,
enableWebSearch,
enableNoteTools,
contextNoteId,

View File

@@ -19,7 +19,9 @@ export interface ProviderType {
}
export const PROVIDER_TYPES: ProviderType[] = [
{ id: "anthropic", name: "Anthropic" }
{ id: "anthropic", name: "Anthropic" },
{ id: "openai", name: "OpenAI" },
{ id: "google", name: "Google Gemini" }
];
interface AddProviderModalProps {

View File

@@ -30,8 +30,10 @@
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.0",
"ai": "^5.0.0",
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/google": "3.0.54",
"@ai-sdk/openai": "3.0.49",
"ai": "6.0.142",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.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,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

@@ -2,7 +2,7 @@ import type { LlmMessage } from "@triliumnext/commons";
import type { Request, Response } from "express";
import { generateChatTitle } from "../../services/llm/chat_title.js";
import { getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
import { getAllModels, getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
import { streamToChunks } from "../../services/llm/stream.js";
import log from "../../services/log.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
@@ -88,19 +88,14 @@ async function streamChat(req: Request, res: Response) {
}
/**
* Get available models for a provider.
* Get available models from all configured providers.
*/
function getModels(req: Request, _res: Response) {
const providerType = req.query.provider as string || "anthropic";
// Return empty array when no providers configured - client handles this gracefully
function getModels(_req: Request, _res: Response) {
if (!hasConfiguredProviders()) {
return { models: [] };
}
const llmProvider = getProviderByType(providerType);
const models = llmProvider.getAvailableModels();
return { models };
return { models: getAllModels() };
}
export default {

View File

@@ -1,5 +1,7 @@
import type { LlmProvider } from "./types.js";
import type { LlmProvider, ModelInfo } from "./types.js";
import { AnthropicProvider } from "./providers/anthropic.js";
import { GoogleProvider } from "./providers/google.js";
import { OpenAiProvider } from "./providers/openai.js";
import optionService from "../options.js";
import log from "../log.js";
@@ -16,7 +18,9 @@ export interface LlmProviderSetup {
/** Factory functions for creating provider instances */
const providerFactories: Record<string, (apiKey: string) => LlmProvider> = {
anthropic: (apiKey) => new AnthropicProvider(apiKey)
anthropic: (apiKey) => new AnthropicProvider(apiKey),
openai: (apiKey) => new OpenAiProvider(apiKey),
google: (apiKey) => new GoogleProvider(apiKey)
};
/** Cache of instantiated providers by their config ID */
@@ -95,6 +99,35 @@ export function hasConfiguredProviders(): boolean {
return getConfiguredProviders().length > 0;
}
/**
* Get all models from all configured providers, tagged with their provider type.
*/
export function getAllModels(): ModelInfo[] {
const configs = getConfiguredProviders();
const seenProviderTypes = new Set<string>();
const allModels: ModelInfo[] = [];
for (const config of configs) {
// Only include models once per provider type (not per config instance)
if (seenProviderTypes.has(config.provider)) {
continue;
}
seenProviderTypes.add(config.provider);
try {
const provider = getProvider(config.id);
const models = provider.getAvailableModels();
for (const model of models) {
allModels.push({ ...model, provider: config.provider });
}
} catch (e) {
log.error(`Failed to get models from provider ${config.provider}: ${e}`);
}
}
return allModels;
}
/**
* Clear the provider cache. Call this when provider configurations change.
*/

View File

@@ -1,29 +1,15 @@
import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic";
import { generateText, streamText, stepCountIs, type CoreMessage, type ToolSet } from "ai";
import { stepCountIs, streamText, type ModelMessage, type ToolSet } from "ai";
import type { LlmMessage } from "@triliumnext/commons";
import becca from "../../../becca/becca.js";
import { noteTools, attributeTools, currentNoteTools } from "../tools/index.js";
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
const DEFAULT_MODEL = "claude-sonnet-4-6";
const DEFAULT_MAX_TOKENS = 8096;
const TITLE_MODEL = "claude-haiku-4-5-20251001";
const TITLE_MAX_TOKENS = 30;
/**
* Calculate effective cost for comparison (weighted average: 1 input + 3 output).
* Output is weighted more heavily as it's typically the dominant cost factor.
*/
function effectiveCost(pricing: ModelPricing): number {
return (pricing.input + 3 * pricing.output) / 4;
}
import type { LlmProviderConfig, StreamResult } from "../types.js";
import { BaseProvider, buildModelList } from "./base_provider.js";
/**
* Available Anthropic models with pricing (USD per million tokens).
* Source: https://docs.anthropic.com/en/docs/about-claude/models
*/
const BASE_MODELS: Omit<ModelInfo, "costMultiplier">[] = [
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
// ===== Current Models =====
{
id: "claude-sonnet-4-6",
@@ -49,7 +35,7 @@ const BASE_MODELS: Omit<ModelInfo, "costMultiplier">[] = [
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
pricing: { input: 3, output: 15 },
contextWindow: 200000, // 1M available with beta header
contextWindow: 200000,
isLegacy: true
},
{
@@ -70,7 +56,7 @@ const BASE_MODELS: Omit<ModelInfo, "costMultiplier">[] = [
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4.0",
pricing: { input: 3, output: 15 },
contextWindow: 200000, // 1M available with beta header
contextWindow: 200000,
isLegacy: true
},
{
@@ -80,69 +66,42 @@ const BASE_MODELS: Omit<ModelInfo, "costMultiplier">[] = [
contextWindow: 200000,
isLegacy: true
}
];
]);
// Use default model (Sonnet) as baseline for cost multiplier
const baselineModel = BASE_MODELS.find(m => m.isDefault) || BASE_MODELS[0];
const baselineCost = effectiveCost(baselineModel.pricing);
// Build models with cost multipliers
const AVAILABLE_MODELS: ModelInfo[] = BASE_MODELS.map(m => ({
...m,
costMultiplier: Math.round((effectiveCost(m.pricing) / baselineCost) * 10) / 10
}));
// Build pricing lookup from available models
const MODEL_PRICING: Record<string, ModelPricing> = Object.fromEntries(
AVAILABLE_MODELS.map(m => [m.id, m.pricing])
);
/**
* Build a lightweight context hint about the current note (title + type only, no content).
* The full content is available via the get_current_note tool.
*/
function buildNoteHint(noteId: string): string | null {
const note = becca.getNote(noteId);
if (!note) {
return null;
}
return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`;
}
export class AnthropicProvider implements LlmProvider {
export class AnthropicProvider extends BaseProvider {
name = "anthropic";
protected defaultModel = "claude-sonnet-4-6";
protected titleModel = "claude-haiku-4-5-20251001";
protected availableModels = AVAILABLE_MODELS;
protected modelPricing = MODEL_PRICING;
private anthropic: AnthropicSDKProvider;
constructor(apiKey: string) {
super();
if (!apiKey) {
throw new Error("API key is required for Anthropic provider");
}
this.anthropic = createAnthropic({ apiKey });
}
chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
const chatMessages = messages.filter(m => m.role !== "system");
protected createModel(modelId: string) {
return this.anthropic(modelId);
}
// Add a lightweight hint about the current note (content available via tool)
if (config.contextNoteId) {
const noteHint = buildNoteHint(config.contextNoteId);
if (noteHint) {
systemPrompt = systemPrompt
? `${systemPrompt}\n\n${noteHint}`
: noteHint;
}
}
protected override addWebSearchTool(tools: ToolSet): void {
tools.web_search = this.anthropic.tools.webSearch_20250305({
maxUses: 5
});
}
// 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.
/**
* Override buildMessages to add Anthropic-specific cache control breakpoints.
*/
protected override buildMessages(chatMessages: LlmMessage[], systemPrompt: string | undefined): ModelMessage[] {
const CACHE_CONTROL = { anthropic: { cacheControl: { type: "ephemeral" as const } } };
const coreMessages: ModelMessage[] = [];
const coreMessages: CoreMessage[] = [];
// System prompt as a cacheable message
if (systemPrompt) {
coreMessages.push({
role: "system",
@@ -151,94 +110,59 @@ export class AnthropicProvider implements LlmProvider {
});
}
// Conversation messages
for (let i = 0; i < chatMessages.length; i++) {
const m = chatMessages[i];
const isLastBeforeNewTurn = i === chatMessages.length - 2;
// Anthropic rejects empty text content blocks. Replace empty
// content (e.g. tool-only assistant turns) with a placeholder
// to preserve conversation flow.
const content = m.content || "(tool use)";
coreMessages.push({
role: m.role as "user" | "assistant",
content: m.content,
// Cache breakpoint on the second-to-last message:
// everything up to here is identical across consecutive turns.
content,
...(isLastBeforeNewTurn && { providerOptions: CACHE_CONTROL })
});
}
const model = this.anthropic(config.model || DEFAULT_MODEL);
return coreMessages;
}
/**
* Override chat to add Anthropic-specific extended thinking support.
*/
override chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
if (!config.enableExtendedThinking) {
return super.chat(messages, config);
}
const systemPrompt = this.buildSystemPrompt(messages, config);
const chatMessages = messages.filter(m => m.role !== "system");
const coreMessages = this.buildMessages(chatMessages, systemPrompt);
const thinkingBudget = config.thinkingBudget || 10000;
const maxTokens = Math.max(config.maxTokens || 8096, thinkingBudget + 4000);
// Build options for streamText
const streamOptions: Parameters<typeof streamText>[0] = {
model,
model: this.createModel(config.model || this.defaultModel),
messages: coreMessages,
maxOutputTokens: config.maxTokens || DEFAULT_MAX_TOKENS
};
// Enable extended thinking for deeper reasoning
if (config.enableExtendedThinking) {
const thinkingBudget = config.thinkingBudget || 10000;
streamOptions.providerOptions = {
maxOutputTokens: maxTokens,
providerOptions: {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: thinkingBudget
}
}
};
streamOptions.maxOutputTokens = Math.max(
streamOptions.maxOutputTokens || DEFAULT_MAX_TOKENS,
thinkingBudget + 4000
);
}
// Build tools object
const tools: ToolSet = {};
if (config.enableWebSearch) {
tools.web_search = this.anthropic.tools.webSearch_20250305({
maxUses: 5
});
}
if (config.contextNoteId) {
Object.assign(tools, currentNoteTools(config.contextNoteId));
}
if (config.enableNoteTools) {
Object.assign(tools, noteTools);
Object.assign(tools, attributeTools);
}
}
};
const tools = this.buildTools(config);
if (Object.keys(tools).length > 0) {
streamOptions.tools = tools;
// Allow multiple tool use cycles before final response
streamOptions.stopWhen = stepCountIs(5);
// Let model decide when to use tools vs respond with text
streamOptions.toolChoice = "auto";
}
return streamText(streamOptions);
}
getModelPricing(model: string): ModelPricing | undefined {
return MODEL_PRICING[model];
}
getAvailableModels(): ModelInfo[] {
return AVAILABLE_MODELS;
}
async generateTitle(firstMessage: string): Promise<string> {
const { text } = await generateText({
model: this.anthropic(TITLE_MODEL),
maxOutputTokens: TITLE_MAX_TOKENS,
messages: [
{
role: "user",
content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}`
}
]
});
return text.trim();
}
}

View File

@@ -0,0 +1,188 @@
/**
* Base class for LLM providers. Handles shared logic for system prompt building,
* tool assembly, model pricing, and title generation.
*/
import { generateText, streamText, stepCountIs, type ModelMessage, type ToolSet } from "ai";
import type { LanguageModel } from "ai";
import type { LlmMessage } from "@triliumnext/commons";
import becca from "../../../becca/becca.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_MAX_TOKENS = 8096;
const TITLE_MAX_TOKENS = 30;
/**
* Calculate effective cost for comparison (weighted average: 1 input + 3 output).
* Output is weighted more heavily as it's typically the dominant cost factor.
*/
function effectiveCost(pricing: ModelPricing): number {
return (pricing.input + 3 * pricing.output) / 4;
}
/**
* Build a lightweight context hint about the current note (title + type only, no content).
*/
function buildNoteHint(noteId: string): string | null {
const note = becca.getNote(noteId);
if (!note) {
return null;
}
return `The user is currently viewing a ${note.type} note titled "${note.title}". Use the get_current_note tool to read its content if needed.`;
}
/**
* Build the model list with cost multipliers from a base model definition array.
*/
export function buildModelList(baseModels: Omit<ModelInfo, "costMultiplier">[]): {
models: ModelInfo[];
pricing: Record<string, ModelPricing>;
} {
const baselineModel = baseModels.find(m => m.isDefault) || baseModels[0];
const baselineCost = effectiveCost(baselineModel.pricing);
const models = baseModels.map(m => ({
...m,
costMultiplier: Math.round((effectiveCost(m.pricing) / baselineCost) * 10) / 10
}));
const pricing = Object.fromEntries(
models.map(m => [m.id, m.pricing])
);
return { models, pricing };
}
export abstract class BaseProvider implements LlmProvider {
abstract name: string;
protected abstract defaultModel: string;
protected abstract titleModel: string;
protected abstract availableModels: ModelInfo[];
protected abstract modelPricing: Record<string, ModelPricing>;
/** Create a language model instance for the given model ID. */
protected abstract createModel(modelId: string): LanguageModel;
/**
* Build the system prompt with note hints and skills summary.
*/
protected buildSystemPrompt(messages: LlmMessage[], config: LlmProviderConfig): string | undefined {
let systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
if (config.contextNoteId) {
const noteHint = buildNoteHint(config.contextNoteId);
if (noteHint) {
systemPrompt = systemPrompt
? `${systemPrompt}\n\n${noteHint}`
: noteHint;
}
}
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;
}
return systemPrompt;
}
/**
* Build the ModelMessage array from LlmMessages (no provider-specific options).
*/
protected buildMessages(chatMessages: LlmMessage[], systemPrompt: string | undefined): ModelMessage[] {
const coreMessages: ModelMessage[] = [];
if (systemPrompt) {
coreMessages.push({ role: "system", content: systemPrompt });
}
for (const m of chatMessages) {
coreMessages.push({
role: m.role as "user" | "assistant",
content: m.content
});
}
return coreMessages;
}
/**
* Add provider-specific web search tool. Override in subclasses that support it.
*/
protected addWebSearchTool(_tools: ToolSet): void {}
/**
* Build the tool set based on config.
*/
protected buildTools(config: LlmProviderConfig): ToolSet {
const tools: ToolSet = {};
if (config.enableWebSearch) {
this.addWebSearchTool(tools);
}
if (config.contextNoteId) {
Object.assign(tools, currentNoteTools(config.contextNoteId));
}
if (config.enableNoteTools) {
Object.assign(tools, noteTools);
Object.assign(tools, attributeTools);
Object.assign(tools, hierarchyTools);
Object.assign(tools, skillTools);
}
return tools;
}
chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
const systemPrompt = this.buildSystemPrompt(messages, config);
const chatMessages = messages.filter(m => m.role !== "system");
const coreMessages = this.buildMessages(chatMessages, systemPrompt);
const streamOptions: Parameters<typeof streamText>[0] = {
model: this.createModel(config.model || this.defaultModel),
messages: coreMessages,
maxOutputTokens: config.maxTokens || DEFAULT_MAX_TOKENS
};
const tools = this.buildTools(config);
if (Object.keys(tools).length > 0) {
streamOptions.tools = tools;
streamOptions.stopWhen = stepCountIs(5);
streamOptions.toolChoice = "auto";
}
return streamText(streamOptions);
}
getModelPricing(model: string): ModelPricing | undefined {
return this.modelPricing[model];
}
getAvailableModels(): ModelInfo[] {
return this.availableModels;
}
async generateTitle(firstMessage: string): Promise<string> {
const { text } = await generateText({
model: this.createModel(this.titleModel),
maxOutputTokens: TITLE_MAX_TOKENS,
messages: [
{
role: "user",
content: `Summarize the following message as a very short chat title (max 6 words). Reply with ONLY the title, no quotes or punctuation at the end.\n\nMessage: ${firstMessage}`
}
]
});
return text.trim();
}
}

View File

@@ -0,0 +1,102 @@
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google";
import { streamText, stepCountIs, type ToolSet } from "ai";
import type { LlmMessage } from "@triliumnext/commons";
import type { LlmProviderConfig, StreamResult } from "../types.js";
import { BaseProvider, buildModelList } from "./base_provider.js";
/**
* Available Google Gemini models with pricing (USD per million tokens).
* Source: https://ai.google.dev/gemini-api/docs/pricing
*/
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
// ===== Current Models =====
{
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
pricing: { input: 1.25, output: 10 },
contextWindow: 1048576
},
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
pricing: { input: 0.3, output: 2.5 },
contextWindow: 1048576,
isDefault: true
},
{
id: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash-Lite",
pricing: { input: 0.1, output: 0.4 },
contextWindow: 1048576
},
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
pricing: { input: 0.1, output: 0.4 },
contextWindow: 1048576,
isLegacy: true
}
]);
export class GoogleProvider extends BaseProvider {
name = "google";
protected defaultModel = "gemini-2.5-flash";
protected titleModel = "gemini-2.5-flash-lite";
protected availableModels = AVAILABLE_MODELS;
protected modelPricing = MODEL_PRICING;
private google: GoogleGenerativeAIProvider;
constructor(apiKey: string) {
super();
if (!apiKey) {
throw new Error("API key is required for Google provider");
}
this.google = createGoogleGenerativeAI({ apiKey });
}
protected createModel(modelId: string) {
return this.google(modelId);
}
protected override addWebSearchTool(tools: ToolSet): void {
tools.google_search = this.google.tools.googleSearch({});
}
/**
* Override chat to add Google-specific extended thinking support.
* Gemini 2.5 uses thinkingBudget, Gemini 3.x uses thinkingLevel.
*/
override chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult {
if (!config.enableExtendedThinking) {
return super.chat(messages, config);
}
const systemPrompt = this.buildSystemPrompt(messages, config);
const chatMessages = messages.filter(m => m.role !== "system");
const coreMessages = this.buildMessages(chatMessages, systemPrompt);
const streamOptions: Parameters<typeof streamText>[0] = {
model: this.createModel(config.model || this.defaultModel),
messages: coreMessages,
maxOutputTokens: config.maxTokens || 8096,
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: config.thinkingBudget || 10000
}
}
}
};
const tools = this.buildTools(config);
if (Object.keys(tools).length > 0) {
streamOptions.tools = tools;
streamOptions.stopWhen = stepCountIs(5);
streamOptions.toolChoice = "auto";
}
return streamText(streamOptions);
}
}

View File

@@ -0,0 +1,84 @@
import { createOpenAI, type OpenAIProvider as OpenAISDKProvider } from "@ai-sdk/openai";
import type { ToolSet } from "ai";
import { BaseProvider, buildModelList } from "./base_provider.js";
/**
* Available OpenAI models with pricing (USD per million tokens).
* Source: https://platform.openai.com/docs/pricing
*/
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
// ===== Current Models =====
{
id: "gpt-4.1",
name: "GPT-4.1",
pricing: { input: 2, output: 8 },
contextWindow: 1047576,
isDefault: true
},
{
id: "gpt-4.1-mini",
name: "GPT-4.1 Mini",
pricing: { input: 0.4, output: 1.6 },
contextWindow: 1047576
},
{
id: "gpt-4.1-nano",
name: "GPT-4.1 Nano",
pricing: { input: 0.1, output: 0.4 },
contextWindow: 1047576
},
{
id: "o3",
name: "o3",
pricing: { input: 2, output: 8 },
contextWindow: 200000
},
{
id: "o4-mini",
name: "o4-mini",
pricing: { input: 1.1, output: 4.4 },
contextWindow: 200000
},
// ===== Legacy Models =====
{
id: "gpt-4o",
name: "GPT-4o",
pricing: { input: 2.5, output: 10 },
contextWindow: 128000,
isLegacy: true
},
{
id: "gpt-4o-mini",
name: "GPT-4o Mini",
pricing: { input: 0.15, output: 0.6 },
contextWindow: 128000,
isLegacy: true
}
]);
export class OpenAiProvider extends BaseProvider {
name = "openai";
protected defaultModel = "gpt-4.1";
protected titleModel = "gpt-4.1-mini";
protected availableModels = AVAILABLE_MODELS;
protected modelPricing = MODEL_PRICING;
private openai: OpenAISDKProvider;
constructor(apiKey: string) {
super();
if (!apiKey) {
throw new Error("API key is required for OpenAI provider");
}
this.openai = createOpenAI({ apiKey });
}
protected createModel(modelId: string) {
return this.openai(modelId);
}
protected override addWebSearchTool(tools: ToolSet): void {
tools.web_search = this.openai.tools.webSearch();
}
}

View File

@@ -0,0 +1,78 @@
/**
* 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 { readFile } from "fs/promises";
import { join } from "path";
import { tool } from "ai";
import { z } from "zod";
import resourceDir from "../../resource_dir.js";
const SKILLS_DIR = join(resourceDir.RESOURCE_DIR, "llm", "skills");
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"
}
];
async function loadSkillContent(name: string): Promise<string | null> {
const skill = SKILLS.find((s) => s.name === name);
if (!skill) {
return null;
}
return readFile(join(SKILLS_DIR, 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 = await 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,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 {
@@ -168,14 +186,27 @@ export const appendToNote = tool({
* Create a new note.
*/
export const createNote = tool({
description: "Create a new note in the user's knowledge base. Returns the created note's ID and title.",
description: [
"Create a new note in the user's knowledge base. Returns the created note's ID and title.",
"Set type to 'text' for rich text notes (content in Markdown) or 'code' for code notes (must also set mime).",
"Common mime values for code notes:",
"'application/javascript;env=frontend' (JS frontend),",
"'application/javascript;env=backend' (JS backend),",
"'text/jsx' (Preact JSX, preferred for frontend widgets),",
"'text/css', 'text/html', 'application/json', 'text/x-python', 'text/x-sh'."
].join(" "),
inputSchema: z.object({
parentNoteId: z.string().describe("The ID of the parent note where the new note will be created. Use 'root' for top-level notes."),
parentNoteId: z.string().describe("The ID of the parent note. Use 'root' for top-level notes."),
title: z.string().describe("The title of the new note"),
content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes)"),
type: z.enum(["text", "code"]).optional().describe("The type of note to create. Defaults to 'text'.")
type: z.enum(["text", "code"]).describe("The type of note to create."),
mime: z.string().optional().describe("MIME type, REQUIRED for code notes (e.g. 'application/javascript;env=backend', 'text/jsx'). Ignored for text notes.")
}),
execute: async ({ parentNoteId, title, content, type = "text" }) => {
execute: async ({ parentNoteId, title, content, type, mime }) => {
if (type === "code" && !mime) {
return { error: "mime is required when creating code notes" };
}
const parentNote = becca.getNote(parentNoteId);
if (!parentNote) {
return { error: "Parent note not found" };
@@ -193,7 +224,8 @@ export const createNote = tool({
parentNoteId,
title,
content: htmlContent,
type
type,
...(mime ? { mime } : {})
});
return {

View File

@@ -38,6 +38,8 @@ export interface ModelInfo {
id: string;
/** Human-readable name (e.g., "Claude Sonnet 4") */
name: string;
/** Provider type that owns this model (e.g., "anthropic", "openai") */
provider?: string;
/** Pricing per million tokens */
pricing: ModelPricing;
/** Whether this is the default model */

View File

@@ -63,6 +63,8 @@ export interface LlmModelInfo {
id: string;
/** Human-readable name (e.g., "Claude Sonnet 4") */
name: string;
/** Provider type that owns this model (e.g., "anthropic", "openai") */
provider?: string;
/** Pricing per million tokens */
pricing: LlmModelPricing;
/** Whether this is the default model */

112
pnpm-lock.yaml generated
View File

@@ -557,11 +557,17 @@ importers:
apps/server:
dependencies:
'@ai-sdk/anthropic':
specifier: ^2.0.0
version: 2.0.71(zod@4.3.6)
specifier: 3.0.64
version: 3.0.64(zod@4.3.6)
'@ai-sdk/google':
specifier: 3.0.54
version: 3.0.54(zod@4.3.6)
'@ai-sdk/openai':
specifier: 3.0.49
version: 3.0.49(zod@4.3.6)
ai:
specifier: ^5.0.0
version: 5.0.161(zod@4.3.6)
specifier: 6.0.142
version: 6.0.142(zod@4.3.6)
better-sqlite3:
specifier: 12.8.0
version: 12.8.0
@@ -1536,26 +1542,38 @@ packages:
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
'@ai-sdk/anthropic@2.0.71':
resolution: {integrity: sha512-JXTtAwlyxGzzRtpiAXk/O93aOTgdfoVX28EoUuRNVqZRgtkoniLQTtqeb8uZ4oXljNJlXzaJLNasS/U90w/wjw==}
'@ai-sdk/anthropic@3.0.64':
resolution: {integrity: sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@2.0.65':
resolution: {integrity: sha512-yaWzvQQWgAzV0m3eidfpRub1+PggDOr2hLnSOI+L2ZispyJ/7EoSzhjKzNCADj6PHnnPaOMH933Xhl1Z/NSxJw==}
'@ai-sdk/gateway@3.0.84':
resolution: {integrity: sha512-RnUw6UNvkaw9MEaJU9cIjA+WBP+ZR5+M/9nfbfJHcGKtTbcWXijJuYKx9nYRnm+qU+iiakb0XvQA/vvho6lTsw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.22':
resolution: {integrity: sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==}
'@ai-sdk/google@3.0.54':
resolution: {integrity: sha512-EgYYdA2LpHZefLDU/FIpmeTlL5Hi4WKQZY3nACMh0wVhrS1fAvlfrdwnD1G4ISCOKWMWrMcRZX9ubs3NM/KHfA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@2.0.1':
resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==}
'@ai-sdk/openai@3.0.49':
resolution: {integrity: sha512-U2f0pCyNn/jQH3wjgxr8o9VvCkuDFTtXbIhbFFtgXqCzMbed6rBnvzQcAMEK0/Pa44byL9zfcvCOFOflvkRA8w==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.21':
resolution: {integrity: sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@aklinker1/rollup-plugin-visualizer@5.12.0':
@@ -7366,8 +7384,8 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ai@5.0.161:
resolution: {integrity: sha512-CVANs7auUNEi/hRhdJDKcPYaCLWXveIfmoiekNSRel3i8WUieB6iEncDS5smcubWsx7hGtTgXxNRTg0YG0ljtA==}
ai@6.0.142:
resolution: {integrity: sha512-ZoxAsnTL/dFg5WdcwC8QNhKVlLtqwwT3I7p/4i8IJJP+6ZwqF1ljuwMsAsPYYvppZ+RzUxjxxFGb1cbEhNH3dg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -16043,27 +16061,39 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
'@ai-sdk/anthropic@2.0.71(zod@4.3.6)':
'@ai-sdk/anthropic@3.0.64(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.22(zod@4.3.6)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
zod: 4.3.6
'@ai-sdk/gateway@2.0.65(zod@4.3.6)':
'@ai-sdk/gateway@3.0.84(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.22(zod@4.3.6)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
'@vercel/oidc': 3.1.0
zod: 4.3.6
'@ai-sdk/provider-utils@3.0.22(zod@4.3.6)':
'@ai-sdk/google@3.0.54(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
zod: 4.3.6
'@ai-sdk/openai@3.0.49(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
zod: 4.3.6
'@ai-sdk/provider-utils@4.0.21(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 4.3.6
'@ai-sdk/provider@2.0.1':
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
@@ -17014,6 +17044,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-cloud-services@47.6.1':
dependencies:
@@ -17031,6 +17063,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-collaboration-core@47.6.1':
dependencies:
@@ -17259,8 +17293,6 @@ snapshots:
'@ckeditor/ckeditor5-table': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-emoji@47.6.1':
dependencies:
@@ -17396,6 +17428,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-highlight@47.6.1':
dependencies:
@@ -17405,6 +17439,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-horizontal-line@47.6.1':
dependencies:
@@ -17425,6 +17461,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-support@47.6.1':
dependencies:
@@ -17459,6 +17497,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-import-word@47.6.1':
dependencies:
@@ -17482,6 +17522,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-inspector@5.0.0': {}
@@ -17492,6 +17534,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-line-height@47.6.1':
dependencies:
@@ -17516,6 +17560,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-list-multi-level@47.6.1':
dependencies:
@@ -17540,6 +17586,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-markdown-gfm@47.6.1':
dependencies:
@@ -17577,6 +17625,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-mention@47.6.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)':
dependencies:
@@ -17606,8 +17656,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-operations-compressor@47.6.1':
dependencies:
@@ -24558,11 +24606,11 @@ snapshots:
clean-stack: 2.2.0
indent-string: 4.0.0
ai@5.0.161(zod@4.3.6):
ai@6.0.142(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 2.0.65(zod@4.3.6)
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.22(zod@4.3.6)
'@ai-sdk/gateway': 3.0.84(zod@4.3.6)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
'@opentelemetry/api': 1.9.0
zod: 4.3.6
@@ -25405,6 +25453,8 @@ snapshots:
ckeditor5-collaboration@47.6.1:
dependencies:
'@ckeditor/ckeditor5-collaboration-core': 47.6.1
transitivePeerDependencies:
- supports-color
ckeditor5-premium-features@47.6.1(bufferutil@4.0.9)(ckeditor5@47.6.1)(utf-8-validate@6.0.5):
dependencies: