fix(ai): support poster queries and complete note operations
This commit is contained in:
1 parent
dc15724d73
commit
abdf9846ef
9 files changed
+81
-11
No files matched your search
@@ -492,6 +492,10 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
message: "This is an editable diagram. Change it with update_diagram, not update_memo.",
|
||||
});
|
||||
}
|
||||
if (memo.poster) {
|
||||
return done({ id: memo.id, title: memo.title, notebookId: memo.notebookId, tags: memo.tags, revision: memo.revision,
|
||||
poster: true, message: "This is an editable poster. Read its canvas with get_poster and edit with update_poster; do not decode or replace Markdown." });
|
||||
}
|
||||
const content = takeNoteText(memo.contentMarkdown, 8000);
|
||||
if (content.length === memo.contentMarkdown.length) inspected.set(memo.id, memo.revision); else inspected.delete(memo.id);
|
||||
return done({ id: memo.id, title: memo.title, notebookId: memo.notebookId, tags: memo.tags, revision: memo.revision,
|
||||
@@ -517,6 +521,7 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
notebookId: memo.notebookId,
|
||||
notebookName: await notebookName(memo.notebookId),
|
||||
tags: memo.tags,
|
||||
poster: memo.poster,
|
||||
createdAt: memo.createdAt,
|
||||
updatedAt: memo.updatedAt,
|
||||
excerpt: takeNoteText(memo.excerpt, 180),
|
||||
|
||||
@@ -50,7 +50,7 @@ When the user asks for a mind map, 思维导图, flowchart, 流程图, architect
|
||||
When the user asks to generate a poster or 海报笔记, call create_poster_memo with concise copy. It creates the actual editable poster type. Never substitute poster-style Markdown, manually encode source, or search local files for the format. Report tool failure honestly. If the user did not name a notebook, omit notebookId. Reply with the new note link.
|
||||
When the user asks for an infographic, 信息图, 資訊圖, or インフォグラフィック, call create_infographic_memo. Do not use create_diagram_memo, create_memo, Markdown, or Mermaid for that request. A share, proportion, or 占比 is a pie infographic: template chart-pie-donut-plain-text and numeric data.values, not a mind map. If the user did not supply the figures, say in data.desc that they are illustrative and are not an official disclosure. If the user did not name a notebook, omit notebookId. Reply with the new note link.
|
||||
When the user asks for a 多维表格, 多維表格, or structured table, call create_table_memo with explicit fields. Do not use create_memo or a Markdown table. If no records were supplied or requested, leave it empty. If records were requested, call get_table_records for the new table's field IDs and revision before add_table_record. Omit notebookId when no notebook was named. Reply with the new note link.
|
||||
To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo.
|
||||
To find or list posters/海报笔记, call search_memos or list_memos with poster=true; the type is not a title keyword. To read a poster's canvas, call get_poster. To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo. To delete a poster, use trash_memos with exact IDs from the query results; it goes to the recycle bin and can be restored with restore_memos.
|
||||
To change an existing diagram, call get_diagram then update_diagram. Do not create a second diagram unless the user asked for a new note. Do not use update_memo or Mermaid for diagrams.
|
||||
A structured table cannot be replaced with update_memo. Use get_table_records to read its structure and records. Do not rewrite its Markdown.
|
||||
You can list, create, update, use, and delete note templates, and list, create, update, and delete AI instructions. use_note_template creates a memo immediately. You cannot empty the trash, share notes, or upload files. Do not claim those tools exist.
|
||||
|
||||
@@ -48,6 +48,29 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe("shared companion MCP adapter", () => {
|
||||
test("poster CRUD reuses scoped queries, guarded edits, recoverable trash and restoration", async () => {
|
||||
const f = await setup();
|
||||
const tools = createCompanionTools({ ...f, scope, signal: new AbortController().signal, assertActive: async () => {}, sources: [] });
|
||||
const created = await tools.create_poster_memo.execute({ notebookId: "ideas", title: "Energy poster", heading: "Protect your energy" });
|
||||
const listed = await tools.list_memos.execute({ poster: true });
|
||||
expect(listed.memos.map(memo => memo.id)).toContain(created.id);
|
||||
expect(listed.memos.find(memo => memo.id === created.id).poster).toBe(true);
|
||||
expect(await tools.get_memo.execute({ memoId: created.id })).toMatchObject({ poster: true, message: expect.stringContaining("get_poster") });
|
||||
await tools.get_poster.execute({ memoId: created.id });
|
||||
const poster = await getMemoDetail(f.db, scope.workspaceId, created.id);
|
||||
const heading = parsePosterDocument(poster.contentMarkdown).elements.find(element => element.role === "title");
|
||||
await tools.update_poster.execute({ memoId: created.id, expectedRevision: poster.revision, edits: [{ id: heading.id, patch: { text: "Focus on what matters" } }] });
|
||||
expect(parsePosterDocument((await getMemoDetail(f.db, scope.workspaceId, created.id)).contentMarkdown).copy.title).toBe("Focus on what matters");
|
||||
await tools.trash_memos.execute({ memoIds: [created.id], dryRun: true });
|
||||
expect(await getMemoDetail(f.db, scope.workspaceId, created.id)).not.toBeNull();
|
||||
await tools.trash_memos.execute({ memoIds: [created.id] });
|
||||
expect(await getMemoDetail(f.db, scope.workspaceId, created.id)).toBeNull();
|
||||
expect((await tools.search_memos.execute({ poster: true })).memos.map(memo => memo.id)).not.toContain(created.id);
|
||||
const trashed = await tools.list_memos.execute({ poster: true, includeDeleted: true });
|
||||
expect(trashed.memos.map(memo => memo.id)).toContain(created.id);
|
||||
await tools.restore_memos.execute({ memoIds: [created.id] });
|
||||
expect(parsePosterDocument((await getMemoDetail(f.db, scope.workspaceId, created.id)).contentMarkdown).copy.title).toBe("Focus on what matters");
|
||||
});
|
||||
test("reuses the exact reviewed MCP definitions, with no administration or upload tools", () => {
|
||||
expect(COMPANION_MCP_TOOLS).toHaveLength(50);
|
||||
for (const definition of COMPANION_MCP_TOOLS) expect(MCP_TOOLS.includes(definition)).toBe(true);
|
||||
|
||||
@@ -72,6 +72,31 @@ const createFixture = (scopes = ["read:memos", "write:memos"]) => {
|
||||
};
|
||||
|
||||
describe("MCP template and AI instruction management", () => {
|
||||
test("poster queries filter before pagination and include existing posters without matching prose titles", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)").run("nb_posters", "ws_mcp", "Posters");
|
||||
const create = input => callMcpTool(context, auth, "create_memo", { notebookId: "nb_posters", ...input });
|
||||
const first = await create({ title: "精力管理", contentMarkdown: serializePosterDocument(createPosterDocument("精力管理")) });
|
||||
const second = await callMcpTool(context, auth, "create_poster_memo", { notebookId: "nb_posters", title: "精力分配", heading: "保护精力" });
|
||||
const prose = await create({ title: "精力管理海报说明", contentMarkdown: "这只是普通笔记" });
|
||||
const page = await callMcpTool(context, auth, "list_memos", { poster: true, limit: 1 });
|
||||
expect(page.memos).toHaveLength(1);
|
||||
expect(page.memos[0].poster).toBe(true);
|
||||
expect(page.hasMore).toBe(true);
|
||||
const next = await callMcpTool(context, auth, "list_memos", { poster: true, limit: 1, offset: page.nextOffset });
|
||||
expect(new Set([page.memos[0].id, next.memos[0].id])).toEqual(new Set([first.memo.id, second.memo.id]));
|
||||
expect(next.hasMore).toBe(false);
|
||||
const full = await callMcpTool(context, auth, "list_memos", { poster: true, includeContent: true });
|
||||
expect(full.memos.every(memo => parsePosterDocument(memo.contentMarkdown))).toBe(true);
|
||||
const found = await callMcpTool(context, auth, "search_memos", { poster: true, query: "精力" });
|
||||
expect(new Set(found.memos.map(memo => memo.id))).toEqual(new Set([first.memo.id, second.memo.id]));
|
||||
expect(found.memos.every(memo => memo.poster)).toBe(true);
|
||||
const excluded = await callMcpTool(context, auth, "search_memos", { poster: false });
|
||||
expect(excluded.memos.map(memo => memo.id)).toEqual([prose.memo.id]);
|
||||
expect((await callMcpTool(context, auth, "list_memos", { poster: false })).memos.map(memo => memo.id)).toEqual([prose.memo.id]);
|
||||
expect((await callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "search_memos", { poster: true })).memos).toEqual([]);
|
||||
expect((await callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "list_memos", { poster: true })).memos).toEqual([]);
|
||||
});
|
||||
test("creates an actual editable poster from copy without encoded source", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)").run("nb_posters", "ws_mcp", "Posters");
|
||||
|
||||
@@ -164,6 +164,7 @@ export type McpToolDependencies = {
|
||||
offset: number;
|
||||
includeContent: boolean;
|
||||
includeDeleted: boolean;
|
||||
poster?: boolean | null;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
mergeMemosRecord: (
|
||||
@@ -200,6 +201,7 @@ export type McpToolDependencies = {
|
||||
updatedBefore?: string | null;
|
||||
isPinned?: boolean | null;
|
||||
hasResources?: boolean | null;
|
||||
poster?: boolean | null;
|
||||
limit: number;
|
||||
},
|
||||
) => Promise<MemoSummary[]>;
|
||||
@@ -282,6 +284,7 @@ export const callMcpTool = async (
|
||||
updatedAfter: getOptionalString(args.updatedAfter),
|
||||
updatedBefore: getOptionalString(args.updatedBefore),
|
||||
isPinned: typeof args.isPinned === "boolean" ? args.isPinned : null,
|
||||
poster: typeof args.poster === "boolean" ? args.poster : null,
|
||||
hasResources: typeof args.hasResources === "boolean" ? args.hasResources : null,
|
||||
limit: clampNumber(Number(args.limit ?? 20), 1, 50),
|
||||
}),
|
||||
@@ -296,6 +299,7 @@ export const callMcpTool = async (
|
||||
offset: clampNumber(Number(args.offset ?? 0), 0, 100_000),
|
||||
includeContent: args.includeContent === true,
|
||||
includeDeleted: args.includeDeleted === true,
|
||||
poster: typeof args.poster === "boolean" ? args.poster : null,
|
||||
});
|
||||
}
|
||||
case "get_memo": {
|
||||
|
||||
@@ -156,7 +156,7 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "search_memos",
|
||||
description: "Search active EdgeEver memos by text, tag, notebook, time range, pin state, or resource presence. query is optional. For recently created or added notes, pass createdAfter and omit query; do not put this week/最近/新增 in query. Time bounds accept YYYY-MM-DD or ISO date-time.",
|
||||
description: "Search active EdgeEver memos by text, tag, notebook, time range, pin state, resource presence, or poster type. Use poster=true to find editable poster notes (海报笔记); do not search for the word 海报 to identify the type. query is optional. For recently created or added notes, pass createdAfter and omit query; do not put this week/最近/新增 in query. Time bounds accept YYYY-MM-DD or ISO date-time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -170,13 +170,14 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
updatedBefore: { type: "string", description: "ISO 8601 date (YYYY-MM-DD) or date-time. Date-only means 23:59:59.999Z." },
|
||||
isPinned: { type: "boolean" },
|
||||
hasResources: { type: "boolean" },
|
||||
poster: { type: "boolean", description: "true: only editable posters; false: exclude posters; omitted: all types." },
|
||||
limit: { type: "integer", minimum: 1, maximum: 50 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_memos",
|
||||
description: "List EdgeEver memos with pagination. Use includeContent when full Markdown is needed.",
|
||||
description: "List EdgeEver memos with pagination. Use poster=true to list only editable poster notes (海报笔记), false to exclude them. Use includeContent when full Markdown is needed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -186,6 +187,7 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
offset: { type: "integer", minimum: 0 },
|
||||
includeContent: { type: "boolean" },
|
||||
includeDeleted: { type: "boolean" },
|
||||
poster: { type: "boolean", description: "Filter by editable poster type before pagination." },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -538,7 +540,7 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
name: "trash_memos",
|
||||
description: "Move one or more active memos to trash. Use dryRun to preview affected memos.",
|
||||
description: "Move one or more active memos, including editable posters, to trash. This is recoverable through restore_memos. Find the exact IDs with search_memos or list_memos (poster=true for posters); never guess IDs. Use dryRun to preview affected memos.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["memoIds"],
|
||||
|
||||
@@ -18,6 +18,7 @@ export type MemoSummaryRow = {
|
||||
title: string | null;
|
||||
excerpt: string;
|
||||
content_text?: string | null;
|
||||
is_poster?: number;
|
||||
content_markdown?: string | null;
|
||||
tags_json: string;
|
||||
is_pinned: number;
|
||||
@@ -75,6 +76,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({
|
||||
...getDiagramSummary(row.content_markdown),
|
||||
...getInfographicSummary(row.content_markdown),
|
||||
...getPosterSummary(row.content_markdown),
|
||||
...(row.is_poster !== undefined ? { poster: Boolean(row.is_poster) } : {}),
|
||||
...getTableSummary(row.content_markdown),
|
||||
tags: parseJsonArray(row.tags_json),
|
||||
isPinned: Boolean(row.is_pinned),
|
||||
|
||||
@@ -169,6 +169,7 @@ export const searchMemoSummaries = async (
|
||||
updatedBefore?: string | null;
|
||||
isPinned?: boolean | null;
|
||||
hasResources?: boolean | null;
|
||||
poster?: boolean | null;
|
||||
limit: number;
|
||||
}
|
||||
): Promise<MemoSummary[]> => {
|
||||
@@ -226,6 +227,11 @@ export const searchMemoSummaries = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof options.poster === "boolean") {
|
||||
filters.push("(instr(c.content_markdown, '<!-- edgeever-poster-v1:') > 0) = ?");
|
||||
binds.push(options.poster ? 1 : 0);
|
||||
}
|
||||
|
||||
if (q) {
|
||||
const ftsQuery = toFtsQuery(q);
|
||||
const likeQuery = `%${escapeLike(q)}%`;
|
||||
@@ -254,7 +260,7 @@ export const searchMemoSummaries = async (
|
||||
)
|
||||
SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision,
|
||||
c.content_text
|
||||
c.content_text, (instr(c.content_markdown, '<!-- edgeever-poster-v1:') > 0) AS is_poster
|
||||
FROM search_matches s
|
||||
INNER JOIN memos m ON m.id = s.memo_id
|
||||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||||
@@ -273,7 +279,7 @@ export const searchMemoSummaries = async (
|
||||
.prepare(
|
||||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision,
|
||||
c.content_text
|
||||
c.content_text, (instr(c.content_markdown, '<!-- edgeever-poster-v1:') > 0) AS is_poster
|
||||
FROM memos m
|
||||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||||
WHERE ${filters.join(" AND ")}
|
||||
@@ -288,12 +294,15 @@ export const searchMemoSummaries = async (
|
||||
|
||||
export const listMemosForMcp = async (
|
||||
db: DatabaseAdapter,
|
||||
options: { workspaceId: string; notebookId?: string | null; limit: number; offset: number; includeContent: boolean; includeDeleted: boolean }
|
||||
options: { workspaceId: string; notebookId?: string | null; limit: number; offset: number; includeContent: boolean; includeDeleted: boolean; poster?: boolean | null }
|
||||
) => {
|
||||
const notebookId = options.notebookId?.trim() || null;
|
||||
const limit = clampNumber(options.limit, 1, 100);
|
||||
const offset = clampNumber(options.offset, 0, 100_000);
|
||||
const pageSize = limit + 1;
|
||||
const posterFilter = typeof options.poster === "boolean"
|
||||
? `(instr(c.content_markdown, '<!-- edgeever-poster-v1:') > 0) = ${options.poster ? 1 : 0}`
|
||||
: "1 = 1";
|
||||
const deletedFilter = options.includeDeleted ? "1 = 1" : "m.is_deleted = 0";
|
||||
|
||||
if (options.includeContent) {
|
||||
@@ -305,7 +314,7 @@ export const listMemosForMcp = async (
|
||||
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
|
||||
FROM memos m
|
||||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||||
WHERE m.workspace_id = ? AND ${deletedFilter}
|
||||
WHERE m.workspace_id = ? AND ${deletedFilter} AND ${posterFilter}
|
||||
AND (? IS NULL OR m.notebook_id = ?)
|
||||
ORDER BY m.updated_at DESC, m.id ASC
|
||||
LIMIT ? OFFSET ?`
|
||||
@@ -327,10 +336,10 @@ export const listMemosForMcp = async (
|
||||
.prepare(
|
||||
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
|
||||
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision,
|
||||
c.content_text
|
||||
c.content_text, (instr(c.content_markdown, '<!-- edgeever-poster-v1:') > 0) AS is_poster
|
||||
FROM memos m
|
||||
INNER JOIN memo_contents c ON c.memo_id = m.id
|
||||
WHERE m.workspace_id = ? AND ${deletedFilter}
|
||||
WHERE m.workspace_id = ? AND ${deletedFilter} AND ${posterFilter}
|
||||
AND (? IS NULL OR m.notebook_id = ?)
|
||||
ORDER BY m.updated_at DESC, m.id ASC
|
||||
LIMIT ? OFFSET ?`
|
||||
|
||||
@@ -996,7 +996,7 @@ export function createAcpHostRuntime(options = {}) {
|
||||
if (mcpBridge) {
|
||||
content.blocks.splice(input.adapterId === "codex" ? 1 : 0, 0, {
|
||||
type: "text",
|
||||
text: "For EdgeEver note operations in this conversation, use only the session-provided MCP server named edgeever-current-workspace. It is connected to the account currently signed in to EdgeEver. Ignore any EdgeEver MCP server from your persistent configuration, which may target a different instance or account. For a request to generate a poster or 海报笔记, use create_poster_memo with concise copy; it produces the actual editable poster type. Never manually encode poster source, inspect local files to discover the format, or substitute poster-style Markdown. If the tool is unavailable or fails, report that clearly. When you create a note, include a clickable Markdown link to it in your final answer: [note title](#memo=exact_memo_id_from_tool_result). Do not show the note ID as plain text or use an https://edgeever.ai/memo/ URL; that URL opens an external website instead of the current workspace.",
|
||||
text: "For EdgeEver note operations in this conversation, use only the session-provided MCP server named edgeever-current-workspace. It is connected to the account currently signed in to EdgeEver. Ignore any EdgeEver MCP server from your persistent configuration, which may target a different instance or account. For a request to generate a poster or 海报笔记, use create_poster_memo with concise copy; it produces the actual editable poster type. Never manually encode poster source, inspect local files to discover the format, or substitute poster-style Markdown. If the tool is unavailable or fails, report that clearly. To query posters, use search_memos or list_memos with poster=true rather than searching the title for 海报. Read and edit a poster with get_poster then update_poster. Delete exact queried IDs with trash_memos; restore them with restore_memos. When you create a note, include a clickable Markdown link to it in your final answer: [note title](#memo=exact_memo_id_from_tool_result). Do not show the note ID as plain text or use an https://edgeever.ai/memo/ URL; that URL opens an external website instead of the current workspace.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user