fix(ai): support poster queries and complete note operations

This commit is contained in:
tianma-if committed 2026-10-11 16:13:11 +08:00
1 parent dc15724d73
commit abdf9846ef
9 files changed
+81 -11

No files matched your search

+5
View File
@@ -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),
+1 -1
View File
@@ -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");
+4
View File
@@ -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": {
+5 -3
View File
@@ -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"],
+2
View File
@@ -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),
+15 -6
View File
@@ -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 ?`
+1 -1
View File
@@ -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) {