feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user