diff --git a/apps/server/src/services/llm/providers/base_provider.ts b/apps/server/src/services/llm/providers/base_provider.ts index b2bb779c34..44662ae1e7 100644 --- a/apps/server/src/services/llm/providers/base_provider.ts +++ b/apps/server/src/services/llm/providers/base_provider.ts @@ -12,7 +12,7 @@ import yaml from "js-yaml"; import becca from "../../../becca/becca.js"; import { getSkillsSummary } from "../skills/index.js"; import { allToolRegistries } from "../tools/index.js"; -import { getNoteMeta } from "../tools/note_tools.js"; +import { getNoteMeta } from "../tools/helpers.js"; import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js"; const DEFAULT_MAX_TOKENS = 8096; diff --git a/apps/server/src/services/llm/tools/helpers.ts b/apps/server/src/services/llm/tools/helpers.ts new file mode 100644 index 0000000000..1b52bad93b --- /dev/null +++ b/apps/server/src/services/llm/tools/helpers.ts @@ -0,0 +1,141 @@ +/** + * Shared helpers for LLM tools — content conversion, metadata building, and previews. + */ + +import type BAttachment from "../../../becca/entities/battachment.js"; +import type BNote from "../../../becca/entities/bnote.js"; +import becca from "../../../becca/becca.js"; +import markdownExport from "../../export/markdown.js"; +import markdownImport from "../../import/markdown.js"; + +const CONTENT_PREVIEW_MAX_LENGTH = 500; +const ATTACHMENT_PREVIEW_MAX_LENGTH = 200; + +/** + * Convert note content to a format suitable for LLM consumption. + * Text notes are converted from HTML to Markdown to reduce token usage. + */ +export function getNoteContentForLlm(note: { type: string; blobId?: string; getContent: () => string | Buffer }) { + const content = note.getContent(); + if (typeof content !== "string") { + // For binary content (images, files), use extracted text if available. + const blob = note.blobId ? becca.getBlob({ blobId: note.blobId }) : null; + if (blob?.textRepresentation) { + return `[extracted text from ${note.type}]\n${blob.textRepresentation}`; + } + return "[binary content]"; + } + if (note.type === "text") { + return markdownExport.toMarkdown(content); + } + return content; +} + +/** + * Convert LLM-provided content to a format suitable for storage. + * For text notes, converts Markdown to HTML. + */ +export function setNoteContentFromLlm(note: { type: string; title: string; setContent: (content: string) => void }, content: string) { + if (note.type === "text") { + note.setContent(markdownImport.renderToHtml(content, note.title)); + } else { + note.setContent(content); + } +} + +/** + * Return a short plain-text content preview for a note, truncated to + * {@link CONTENT_PREVIEW_MAX_LENGTH} characters. Useful for giving an LLM a + * glimpse of the content without sending the full body. + */ +export function getContentPreview(note: { type: string; blobId?: string; getContent: () => string | Buffer; isContentAvailable: () => boolean }): string | null { + if (!note.isContentAvailable()) { + return null; + } + + const full = getNoteContentForLlm(note); + if (!full || full === "[binary content]") { + return null; + } + + if (full.length <= CONTENT_PREVIEW_MAX_LENGTH) { + return full; + } + + return `${full.slice(0, CONTENT_PREVIEW_MAX_LENGTH)}…`; +} + +/** + * Return a short content preview for an attachment, or null if no readable + * content is available. For text attachments the raw content is used; for + * binary attachments (PDF, images) the OCR/extracted text is used when present. + */ +export function getAttachmentContentPreview(att: BAttachment): string | null { + let text: string | null = null; + + if (att.hasStringContent()) { + const content = att.getContent(); + text = typeof content === "string" ? content : content.toString("utf-8"); + } else { + const blob = att.blobId ? becca.getBlob({ blobId: att.blobId }) : null; + text = blob?.textRepresentation ?? null; + } + + if (!text) { + return null; + } + + if (text.length <= ATTACHMENT_PREVIEW_MAX_LENGTH) { + return text; + } + + return `${text.slice(0, ATTACHMENT_PREVIEW_MAX_LENGTH)}…`; +} + +/** + * Build the full metadata object for a note. Used by both the `get_note` tool + * and the system prompt. + */ +export function getNoteMeta(note: BNote) { + return { + noteId: note.noteId, + isProtected: note.isProtected, + title: note.title, + type: note.type, + mime: note.mime, + blobId: note.blobId, + dateCreated: note.dateCreated, + dateModified: note.dateModified, + utcDateCreated: note.utcDateCreated, + utcDateModified: note.utcDateModified, + parentNoteIds: note.getParentNotes().map((p) => p.noteId), + childNoteIds: note.getChildNotes().map((ch) => ch.noteId), + parentBranchIds: note.getParentBranches().map((p) => p.branchId), + childBranchIds: note.getChildBranches().map((ch) => ch.branchId), + attributes: note.getAttributes().map((attr) => ({ + attributeId: attr.attributeId, + noteId: attr.noteId, + type: attr.type, + name: attr.name, + value: attr.value, + position: attr.position, + isInheritable: attr.isInheritable, + utcDateModified: attr.utcDateModified + })), + contentPreview: getContentPreview(note), + attachments: note.getAttachments().map((att) => ({ + attachmentId: att.attachmentId, + ownerId: att.ownerId, + role: att.role, + mime: att.mime, + title: att.title, + position: att.position, + blobId: att.blobId, + dateModified: att.dateModified, + utcDateModified: att.utcDateModified, + utcDateScheduledForErasureSince: att.utcDateScheduledForErasureSince, + contentLength: att.contentLength, + contentPreview: getAttachmentContentPreview(att) + })) + }; +} diff --git a/apps/server/src/services/llm/tools/note_tools.ts b/apps/server/src/services/llm/tools/note_tools.ts index a2321f1772..d37017ec9d 100644 --- a/apps/server/src/services/llm/tools/note_tools.ts +++ b/apps/server/src/services/llm/tools/note_tools.ts @@ -4,149 +4,14 @@ import { z } from "zod"; -import type BAttachment from "../../../becca/entities/battachment.js"; -import type BNote from "../../../becca/entities/bnote.js"; import becca from "../../../becca/becca.js"; -import markdownExport from "../../export/markdown.js"; import markdownImport from "../../import/markdown.js"; import noteService from "../../notes.js"; import SearchContext from "../../search/search_context.js"; import searchService from "../../search/services/search.js"; +import { getNoteContentForLlm, getNoteMeta, setNoteContentFromLlm } from "./helpers.js"; import { defineTools } from "./tool_registry.js"; -const CONTENT_PREVIEW_MAX_LENGTH = 500; - -/** - * Return a short plain-text content preview for a note, truncated to - * {@link CONTENT_PREVIEW_MAX_LENGTH} characters. Useful for giving an LLM a - * glimpse of the content without sending the full body. - */ -export function getContentPreview(note: { type: string; blobId?: string; getContent: () => string | Buffer; isContentAvailable: () => boolean }): string | null { - if (!note.isContentAvailable()) { - return null; - } - - const full = getNoteContentForLlm(note); - if (!full || full === "[binary content]") { - return null; - } - - if (full.length <= CONTENT_PREVIEW_MAX_LENGTH) { - return full; - } - - return `${full.slice(0, CONTENT_PREVIEW_MAX_LENGTH)}…`; -} - -/** - * Build the full metadata object for a note. Used by both the `get_note` tool - * and the system prompt. - */ -export function getNoteMeta(note: BNote) { - return { - noteId: note.noteId, - isProtected: note.isProtected, - title: note.title, - type: note.type, - mime: note.mime, - blobId: note.blobId, - dateCreated: note.dateCreated, - dateModified: note.dateModified, - utcDateCreated: note.utcDateCreated, - utcDateModified: note.utcDateModified, - parentNoteIds: note.getParentNotes().map((p) => p.noteId), - childNoteIds: note.getChildNotes().map((ch) => ch.noteId), - parentBranchIds: note.getParentBranches().map((p) => p.branchId), - childBranchIds: note.getChildBranches().map((ch) => ch.branchId), - attributes: note.getAttributes().map((attr) => ({ - attributeId: attr.attributeId, - noteId: attr.noteId, - type: attr.type, - name: attr.name, - value: attr.value, - position: attr.position, - isInheritable: attr.isInheritable, - utcDateModified: attr.utcDateModified - })), - contentPreview: getContentPreview(note), - attachments: note.getAttachments().map((att) => ({ - attachmentId: att.attachmentId, - ownerId: att.ownerId, - role: att.role, - mime: att.mime, - title: att.title, - position: att.position, - blobId: att.blobId, - dateModified: att.dateModified, - utcDateModified: att.utcDateModified, - utcDateScheduledForErasureSince: att.utcDateScheduledForErasureSince, - contentLength: att.contentLength, - contentPreview: getAttachmentContentPreview(att) - })) - }; -} - -/** - * Convert note content to a format suitable for LLM consumption. - * Text notes are converted from HTML to Markdown to reduce token usage. - */ -export function getNoteContentForLlm(note: { type: string; blobId?: string; getContent: () => string | Buffer }) { - const content = note.getContent(); - if (typeof content !== "string") { - // For binary content (images, files), use extracted text if available. - const blob = note.blobId ? becca.getBlob({ blobId: note.blobId }) : null; - if (blob?.textRepresentation) { - return `[extracted text from ${note.type}]\n${blob.textRepresentation}`; - } - return "[binary content]"; - } - if (note.type === "text") { - return markdownExport.toMarkdown(content); - } - return content; -} - -/** - * Convert LLM-provided content to a format suitable for storage. - * For text notes, converts Markdown to HTML. - */ -function setNoteContentFromLlm(note: { type: string; title: string; setContent: (content: string) => void }, content: string) { - if (note.type === "text") { - note.setContent(markdownImport.renderToHtml(content, note.title)); - } else { - note.setContent(content); - } -} - -const ATTACHMENT_PREVIEW_MAX_LENGTH = 200; - -/** - * Return a short content preview for an attachment, or null if no readable - * content is available. For text attachments the raw content is used; for - * binary attachments (PDF, images) the OCR/extracted text is used when present. - */ -function getAttachmentContentPreview(att: BAttachment): string | null { - let text: string | null = null; - - if (att.hasStringContent()) { - const content = att.getContent(); - text = typeof content === "string" ? content : content.toString("utf-8"); - } else { - const blob = att.blobId ? becca.getBlob({ blobId: att.blobId }) : null; - text = blob?.textRepresentation ?? null; - } - - if (!text) { - return null; - } - - if (text.length <= ATTACHMENT_PREVIEW_MAX_LENGTH) { - return text; - } - - return `${text.slice(0, ATTACHMENT_PREVIEW_MAX_LENGTH)}…`; -} - export const noteTools = defineTools({ search_notes: { description: [