191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
/**
|
||
* notes.ts — SearchNotes / ReadNote / WriteNote tools
|
||
*
|
||
* Agents can search, read, and write shared knowledge notes
|
||
* stored under data/users/{userId}/notes/{folder}/{file}.md.
|
||
*/
|
||
|
||
import { ToolDef } from '../../llm/openai-compat.js';
|
||
import { NotesService } from '../../notes/notes-service.js';
|
||
import type { ToolContext, ToolResult } from './core.js';
|
||
|
||
export interface NotesToolContext {
|
||
notesService: NotesService;
|
||
user: Express.User;
|
||
}
|
||
|
||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||
SearchNotes: {
|
||
type: 'function',
|
||
function: {
|
||
name: 'SearchNotes',
|
||
description:
|
||
'購読中の knowledge notes を全文検索 (FTS5)。' +
|
||
'詳細は ReadToolDoc({ name: "SearchNotes" }) で取得可能。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
query: {
|
||
type: 'string',
|
||
description: '検索クエリ文字列(FTS5 構文)',
|
||
},
|
||
folder: {
|
||
type: 'string',
|
||
description: '特定フォルダーのみに絞り込む(省略可)',
|
||
},
|
||
limit: {
|
||
type: 'integer',
|
||
description: '最大取得件数(デフォルト 10、最大 100)',
|
||
},
|
||
},
|
||
required: ['query'],
|
||
},
|
||
},
|
||
},
|
||
|
||
ReadNote: {
|
||
type: 'function',
|
||
function: {
|
||
name: 'ReadNote',
|
||
description:
|
||
'特定 note の全文(frontmatter + 本文)を取得。可視性チェックあり。' +
|
||
'詳細は ReadToolDoc({ name: "ReadNote" }) で取得可能。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
owner_id: {
|
||
type: 'string',
|
||
description: 'note の所有者 user ID',
|
||
},
|
||
folder: {
|
||
type: 'string',
|
||
description: 'フォルダー名',
|
||
},
|
||
file_name: {
|
||
type: 'string',
|
||
description: 'ファイル名(例: foo.md)',
|
||
},
|
||
},
|
||
required: ['owner_id', 'folder', 'file_name'],
|
||
},
|
||
},
|
||
},
|
||
|
||
WriteNote: {
|
||
type: 'function',
|
||
function: {
|
||
name: 'WriteNote',
|
||
description:
|
||
'自分の notes/{folder}/{file}.md に Markdown note を書き込む(作成 / 更新)。' +
|
||
'詳細は ReadToolDoc({ name: "WriteNote" }) で取得可能。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
folder: {
|
||
type: 'string',
|
||
description: 'フォルダー名(英数字・. - _ のみ)',
|
||
},
|
||
file_name: {
|
||
type: 'string',
|
||
description: 'ファイル名(.md で終わる)',
|
||
},
|
||
content: {
|
||
type: 'string',
|
||
description: 'YAML frontmatter を含む完全な Markdown 内容',
|
||
},
|
||
},
|
||
required: ['folder', 'file_name', 'content'],
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Execute a notes tool.
|
||
* Returns null when the tool name is not handled (allows index.ts to fall through).
|
||
*/
|
||
export async function executeNotesTools(
|
||
name: string,
|
||
args: Record<string, unknown>,
|
||
ctx: NotesToolContext | ToolContext,
|
||
): Promise<ToolResult | null> {
|
||
// Extract notes-specific context. When called via index.ts the ctx is a full
|
||
// ToolContext; when called from tests it's a minimal NotesToolContext.
|
||
const notesCtx = ctx as NotesToolContext & ToolContext;
|
||
const notesService = notesCtx.notesService;
|
||
|
||
if (!notesService) return null;
|
||
|
||
// Reconstruct a minimal Express.User from ToolContext fields if needed.
|
||
const user: Express.User = notesCtx.user ?? {
|
||
id: notesCtx.userId ?? '',
|
||
role: (notesCtx as ToolContext).notesUserRole ?? 'user',
|
||
orgIds: (notesCtx as ToolContext).notesUserOrgIds ?? [],
|
||
email: '',
|
||
name: null,
|
||
avatarUrl: null,
|
||
status: 'active',
|
||
defaultVisibility: 'private',
|
||
defaultVisibilityOrgId: null,
|
||
};
|
||
|
||
switch (name) {
|
||
case 'SearchNotes': {
|
||
const rawQuery = String(args['query'] ?? '');
|
||
// Wrap in FTS5 phrase quotes to avoid query syntax injection
|
||
const safeQuery = '"' + rawQuery.replace(/"/g, '""') + '"';
|
||
const rows = notesService.searchNotes({
|
||
user,
|
||
query: safeQuery,
|
||
folder: args['folder'] ? String(args['folder']) : undefined,
|
||
limit: args['limit'] ? Math.min(parseInt(String(args['limit']), 10), 100) : 10,
|
||
consumerSubscribed: true,
|
||
});
|
||
if (rows.length === 0) {
|
||
return { output: 'No notes found matching the query.', isError: false };
|
||
}
|
||
const lines = rows.map(
|
||
(r) =>
|
||
`- ${r.owner_id}/${r.folder}/${r.file_name}` +
|
||
(r.title ? `: ${r.title}` : '') +
|
||
(r.tags_json && r.tags_json !== '[]' ? ` [tags: ${r.tags_json}]` : '')
|
||
);
|
||
return { output: lines.join('\n'), isError: false };
|
||
}
|
||
|
||
case 'ReadNote': {
|
||
const ownerId = String(args['owner_id'] ?? '');
|
||
const folder = String(args['folder'] ?? '');
|
||
const fileName = String(args['file_name'] ?? '');
|
||
const out = notesService.getCrossUserNote({ user, ownerId, folder, fileName });
|
||
if (!out) {
|
||
return { output: 'Note not found or not accessible.', isError: true };
|
||
}
|
||
return { output: out.content, isError: false };
|
||
}
|
||
|
||
case 'WriteNote': {
|
||
try {
|
||
const result = notesService.writeNote({
|
||
ownerId: user.id,
|
||
folder: String(args['folder'] ?? ''),
|
||
fileName: String(args['file_name'] ?? ''),
|
||
content: String(args['content'] ?? ''),
|
||
});
|
||
return {
|
||
output: `Wrote ${result.row.owner_id}/${result.row.folder}/${result.row.file_name}`,
|
||
isError: false,
|
||
};
|
||
} catch (err) {
|
||
return { output: `WriteNote error: ${(err as Error).message}`, isError: true };
|
||
}
|
||
}
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Re-export as executeTool for ToolModule interface compatibility
|
||
export const executeTool = executeNotesTools;
|