Files
edgeever/apps/api/src/index.ts
T
2026-07-01 11:53:51 +08:00

4114 lines
117 KiB
TypeScript

import {
createExcerpt,
DEFAULT_MEMO_TITLE,
docToMarkdown,
docToText,
emptyDoc,
ApiTokenCreateSchema,
DeleteMemosSchema,
LoginSchema,
markdownToDoc,
MemoCreateSchema,
MemoUpdateSchema,
MergeMemosSchema,
MoveMemosSchema,
normalizeTags,
TagRenameSchema,
NotebookCreateSchema,
NotebookUpdateSchema,
type ApiToken,
type CreatedApiToken,
type MemoDetail,
type MemoRevision,
type MemoSummary,
type Notebook,
type NotebookCreateInput,
type Resource,
type ResourceListItem,
type ResourceStorageSummary,
type ProfileSnapshot,
type ProfileSection,
type TagSummary,
type TiptapDoc,
} from "@edgeever/shared";
import { zValidator } from "@hono/zod-validator";
import type { Context } from "hono";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { cors } from "hono/cors";
import openApiSpec from "../../../docs/openapi.json";
type Bindings = {
DB: D1Database;
RESOURCES: R2Bucket;
AI?: Ai;
EDGE_EVER_AUTH_USERNAME?: string;
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
EDGE_EVER_SESSION_TTL_DAYS?: string;
EDGE_EVER_R2_BUCKET_NAME?: string;
EDGE_EVER_PROFILE_MODEL?: string;
};
type AuthContext = {
kind: "user" | "agent";
actorType: "user" | "agent";
actorId: string | null;
username: string;
displayName: string | null;
scopes: string[];
sessionId?: string;
tokenId?: string;
};
type AuditActor = {
actorType: "user" | "agent";
actorId: string | null;
};
type NotebookRow = {
id: string;
parent_id: string | null;
name: string;
slug: string | null;
icon: string | null;
color: string | null;
sort_order: number;
memo_count: number | null;
last_memo_updated_at: string | null;
created_at: string;
updated_at: string;
};
type MemoSummaryRow = {
id: string;
notebook_id: string;
title: string | null;
excerpt: string;
tags_json: string;
is_pinned: number;
is_archived: number;
is_deleted: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
revision: number;
};
type MemoDetailRow = MemoSummaryRow & {
content_json: string;
content_markdown: string;
content_text: string;
source_memo_ids: string;
merge_source_count: number;
merged_into_memo_id: string | null;
content_hash: string;
};
type MemoRevisionRow = {
id: string;
memo_id: string;
revision: number;
title: string | null;
tags_json: string;
content_json: string;
content_markdown: string;
content_text: string;
content_hash: string;
created_by: string;
created_at: string;
};
type UserRow = {
id: string;
username: string;
password_hash: string;
display_name: string | null;
is_disabled: number;
};
type SessionRow = {
id: string;
user_id: string;
username: string;
display_name: string | null;
expires_at: string;
};
type ApiTokenRow = {
id: string;
name: string;
token_value: string | null;
scopes_json: string;
last_used_at: string | null;
expires_at: string | null;
is_revoked: number;
created_at: string;
};
type TagSummaryRow = {
name: string;
memo_count: number;
updated_at: string | null;
};
type MemoTagUpdateRow = {
id: string;
title: string | null;
tags_json: string;
content_text: string;
};
type ResourceRow = {
id: string;
memo_id: string;
original_memo_id: string | null;
bucket_name: string;
object_key: string;
kind: "image" | "attachment";
mime_type: string | null;
filename: string | null;
byte_size: number;
sha256: string | null;
width: number | null;
height: number | null;
created_at: string;
updated_at: string;
};
type ResourceListRow = ResourceRow & {
memo_title: string | null;
memo_excerpt: string | null;
memo_is_deleted: number | null;
};
type ResourceStatsRow = {
total_count: number;
total_bytes: number;
image_count: number;
attachment_count: number;
};
type ProfileSnapshotRow = {
id: string;
profile_json: string;
source_memo_ids_json: string;
memo_count: number;
model: string;
generated_at: string;
created_at: string;
};
type ProfileSourceMemoRow = {
id: string;
title: string | null;
tags_json: string;
updated_at: string;
content_text: string;
};
type AppContext = Context<{ Bindings: Bindings; Variables: { auth: AuthContext } }>;
const SESSION_COOKIE = "edgeever_session";
const PASSWORD_HASH_ALGORITHM = "pbkdf2-sha256";
const PASSWORD_HASH_ITERATIONS = 100_000;
const PASSWORD_HASH_BYTES = 32;
const PASSWORD_SALT_BYTES = 16;
const SESSION_TOKEN_BYTES = 32;
const DEFAULT_SESSION_TTL_DAYS = 30;
const DEFAULT_R2_BUCKET_NAME = "edgeever-resources";
const DEFAULT_PROFILE_MODEL = "@cf/zai-org/glm-5.2";
const PROFILE_MEMO_LIMIT = 200;
const PROFILE_MEMO_TEXT_LIMIT = 1200;
const PROFILE_CHUNK_CHAR_LIMIT = 12000;
const MAX_IMAGE_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_ATTACHMENT_UPLOAD_BYTES = 100 * 1024 * 1024;
const REVISION_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000;
const API_TOKEN_BYTES = 32;
const API_TOKEN_PREFIX = "eev";
const ALL_TOKEN_SCOPES = [
"read:notebooks",
"write:notebooks",
"read:memos",
"write:memos",
"read:resources",
"write:resources",
"read:tags",
"write:tags",
"read:profile",
"write:profile",
] as const;
type TokenScope = (typeof ALL_TOKEN_SCOPES)[number];
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
]);
const app = new Hono<{ Bindings: Bindings; Variables: { auth: AuthContext } }>();
app.use(
"/api/*",
cors({
origin: ["http://127.0.0.1:5173", "http://localhost:5173"],
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
credentials: true,
})
);
app.use(
"/mcp",
cors({
origin: ["http://127.0.0.1:5173", "http://localhost:5173"],
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["GET", "POST", "OPTIONS"],
credentials: true,
})
);
app.get("/api/health", (c) =>
c.json({
ok: true,
name: "edgeever",
runtime: "cloudflare-workers",
})
);
app.get("/api/openapi.json", (c) => c.json(openApiSpec));
app.get("/api/v1/auth/session", async (c) => {
const authRequired = await isAuthRequired(c.env);
if (!authRequired) {
return c.json({
authRequired: false,
authenticated: true,
user: {
id: "local",
username: "owner",
displayName: "Owner",
},
});
}
const auth = await authenticateRequest(c, false);
return c.json({
authRequired: true,
authenticated: Boolean(auth && auth.kind === "user"),
user:
auth && auth.kind === "user"
? {
id: auth.actorId,
username: auth.username,
displayName: auth.displayName,
}
: null,
});
});
app.post("/api/v1/auth/login", zValidator("json", LoginSchema), async (c) => {
const input = c.req.valid("json");
const user = await verifyLogin(c.env, input.username, input.password);
if (!user) {
return unauthorized(c, "Username or password is incorrect.");
}
const session = await createSession(c, user);
setSessionCookie(c, session.token, session.maxAge);
await c.env.DB.batch([
c.env.DB.prepare(`UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`).bind(
isoNow(),
isoNow(),
user.id
),
auditStatement(c.env.DB, "user", user.id, "auth.login", "session", session.id, {
username: user.username,
}),
]);
return c.json({
authRequired: true,
authenticated: true,
user: {
id: user.id,
username: user.username,
displayName: user.display_name,
},
});
});
app.post("/api/v1/auth/logout", async (c) => {
const token = getCookie(c, SESSION_COOKIE);
if (token) {
await revokeSession(c.env.DB, token);
}
deleteCookie(c, SESSION_COOKIE, { path: "/" });
return c.json({ ok: true });
});
app.use("/api/v1/*", async (c, next) => {
if (c.req.path.startsWith("/api/v1/auth/")) {
await next();
return;
}
const authRequired = await isAuthRequired(c.env);
if (!authRequired) {
c.set("auth", {
kind: "user",
actorType: "user",
actorId: null,
username: "owner",
displayName: "Owner",
scopes: [],
});
await next();
return;
}
const auth = await authenticateRequest(c, true);
if (!auth) {
return unauthorized(c, "Authentication required.");
}
c.set("auth", auth);
await next();
});
app.get("/api/v1/api-tokens", async (c) => {
const userOnly = requireUser(c);
if (userOnly) {
return userOnly;
}
const rows = await c.env.DB.prepare(
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at
FROM api_tokens
ORDER BY is_revoked ASC, created_at DESC
LIMIT 200`
).all<ApiTokenRow>();
return c.json({
apiTokens: rows.results.map(mapApiToken),
availableScopes: ALL_TOKEN_SCOPES,
});
});
app.post("/api/v1/api-tokens", zValidator("json", ApiTokenCreateSchema), async (c) => {
const userOnly = requireUser(c);
if (userOnly) {
return userOnly;
}
const input = c.req.valid("json");
const scopes = normalizeTokenScopes(input.scopes);
if (!scopes) {
return badRequest(c, "Token scope is not supported.");
}
const id = createId("tok");
const token = `${API_TOKEN_PREFIX}_${randomToken(API_TOKEN_BYTES)}`;
const now = isoNow();
const actor = getAuditActor(c);
await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO api_tokens (id, name, token_hash, token_value, scopes_json, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).bind(id, input.name, await sha256(token), token, JSON.stringify(scopes), input.expiresAt ?? null, now),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "api_token.create", "api_token", id, {
name: input.name,
scopes,
expiresAt: input.expiresAt ?? null,
}),
]);
const row = await getApiTokenRow(c.env.DB, id);
if (!row) {
return notFound(c, "API token not found");
}
return c.json({ token, apiToken: mapApiToken(row) } satisfies CreatedApiToken, 201);
});
app.delete("/api/v1/api-tokens/:id", async (c) => {
const userOnly = requireUser(c);
if (userOnly) {
return userOnly;
}
const id = c.req.param("id");
const actor = getAuditActor(c);
await c.env.DB.batch([
c.env.DB.prepare(`UPDATE api_tokens SET is_revoked = 1 WHERE id = ?`).bind(id),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "api_token.revoke", "api_token", id, {}),
]);
return c.json({ ok: true });
});
app.get("/api/v1/notebooks", async (c) => {
const denied = requireScopes(c, "read:notebooks");
if (denied) {
return denied;
}
const rows = await c.env.DB.prepare(
notebookSelectSql(
`WHERE n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
)
).all<NotebookRow>();
return c.json({ notebooks: rows.results.map(mapNotebook) });
});
app.post("/api/v1/notebooks", zValidator("json", NotebookCreateSchema), async (c) => {
const denied = requireScopes(c, "write:notebooks");
if (denied) {
return denied;
}
const input = c.req.valid("json");
const actor = getAuditActor(c);
try {
const notebook = await createNotebookRecord(c.env.DB, input, actor);
return c.json({ notebook }, 201);
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.patch("/api/v1/notebooks/:id", zValidator("json", NotebookUpdateSchema), async (c) => {
const denied = requireScopes(c, "write:notebooks");
if (denied) {
return denied;
}
const id = c.req.param("id");
const input = c.req.valid("json");
const actor = getAuditActor(c);
try {
const notebook = await updateNotebookRecord(c.env.DB, id, input, actor);
return c.json({ notebook });
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.delete("/api/v1/notebooks/:id", async (c) => {
const denied = requireScopes(c, "write:notebooks");
if (denied) {
return denied;
}
const id = c.req.param("id");
const actor = getAuditActor(c);
const now = isoNow();
const current = await getNotebook(c.env.DB, id);
if (!current) {
return notFound(c, "Notebook not found");
}
if (id === "nb_inbox") {
return badRequest(c, "等待分类不能删除。");
}
const [childCount, memoCount] = await Promise.all([
c.env.DB.prepare(`SELECT COUNT(*) AS count FROM notebooks WHERE parent_id = ? AND is_deleted = 0`)
.bind(id)
.first<{ count: number }>(),
c.env.DB.prepare(`SELECT COUNT(*) AS count FROM memos WHERE notebook_id = ? AND is_deleted = 0`)
.bind(id)
.first<{ count: number }>(),
]);
if ((childCount?.count ?? 0) > 0 || (memoCount?.count ?? 0) > 0) {
return conflict(c, "notebook_not_empty", "Move or delete child notebooks and memos before deleting this notebook.");
}
await c.env.DB.prepare(
`UPDATE notebooks
SET is_deleted = 1, deleted_at = ?, updated_at = ?
WHERE id = ? AND id <> 'nb_inbox'`
)
.bind(now, now, id)
.run();
await audit(c.env.DB, actor.actorType, actor.actorId, "notebook.delete", "notebook", id, {});
return c.json({ ok: true });
});
app.get("/api/v1/tags", async (c) => {
const denied = requireScopes(c, "read:tags");
if (denied) {
return denied;
}
return c.json({ tags: await listTagSummaries(c.env.DB) });
});
app.patch("/api/v1/tags/:tag", zValidator("json", TagRenameSchema), async (c) => {
const denied = requireScopes(c, "write:tags");
if (denied) {
return denied;
}
const oldTag = decodeTagParam(c.req.param("tag"));
const input = c.req.valid("json");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const updated = await updateTagAcrossMemos(c.env.DB, oldTag, input.name, actor, actorLabel);
return c.json({ ok: true, updated });
});
app.delete("/api/v1/tags/:tag", async (c) => {
const denied = requireScopes(c, "write:tags");
if (denied) {
return denied;
}
const tag = decodeTagParam(c.req.param("tag"));
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const updated = await updateTagAcrossMemos(c.env.DB, tag, null, actor, actorLabel);
return c.json({ ok: true, updated });
});
app.get("/api/v1/profile", async (c) => {
const denied = requireScopes(c, "read:profile");
if (denied) {
return denied;
}
return c.json({ profile: await getLatestProfileSnapshot(c.env.DB) });
});
app.post("/api/v1/profile/generate", async (c) => {
const denied = requireScopes(c, "write:profile");
if (denied) {
return denied;
}
if (!c.env.AI) {
return apiError(c, "ai_not_configured", "Workers AI binding is not configured for this deployment.", 501);
}
const actor = getAuditActor(c);
try {
const profile = await generateProfileSnapshot(c.env.DB, c.env.AI, c.env.EDGE_EVER_PROFILE_MODEL, actor);
return c.json({ profile }, 201);
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.get("/api/v1/memos", async (c) => {
const denied = requireScopes(c, "read:memos");
if (denied) {
return denied;
}
const notebookId = c.req.query("notebookId");
const q = c.req.query("q")?.trim();
const includeTrash = c.req.query("trash") === "1";
const limit = clampNumber(Number(c.req.query("limit") ?? 80), 1, 100);
const deletedClause = includeTrash ? "m.is_deleted = 1" : "m.is_deleted = 0";
if (q) {
const ftsQuery = toFtsQuery(q);
const likeQuery = `%${escapeLike(q)}%`;
if (ftsQuery) {
const rows = await c.env.DB.prepare(
`WITH raw_matches(memo_id, rank) AS (
SELECT memo_id, bm25(memos_fts)
FROM memos_fts
WHERE memos_fts MATCH ?
UNION ALL
SELECT m.id, 100.0
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.title LIKE ? ESCAPE '\\'
OR c.content_text LIKE ? ESCAPE '\\'
OR m.tags_json LIKE ? ESCAPE '\\'
),
search_matches AS (
SELECT memo_id, MIN(rank) AS rank
FROM raw_matches
GROUP BY memo_id
)
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
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
WHERE ${deletedClause}
AND (? IS NULL OR m.notebook_id = ?)
ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC
LIMIT ?`
)
.bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId ?? null, notebookId ?? null, limit)
.all<MemoSummaryRow>();
return c.json({ memos: rows.results.map(mapMemoSummary) });
}
}
const rows = await c.env.DB.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
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE ${deletedClause}
AND (? IS NULL OR m.notebook_id = ?)
ORDER BY ${includeTrash ? "m.deleted_at DESC," : "m.is_pinned DESC,"} m.updated_at DESC
LIMIT ?`
)
.bind(notebookId ?? null, notebookId ?? null, limit)
.all<MemoSummaryRow>();
return c.json({ memos: rows.results.map(mapMemoSummary) });
});
app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const input = c.req.valid("json");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const tags = normalizeTags(input.tags);
const contentMarkdown = input.contentMarkdown ?? "";
const contentJson = markdownToDoc(contentMarkdown);
const contentText = docToText(contentJson);
const title = normalizeMemoTitle(input.title);
const excerpt = createExcerpt(contentText);
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const id = createId("memo");
const now = isoNow();
const createdAt = input.createdAt ?? now;
const updatedAt = input.updatedAt ?? now;
await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO memos (
id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(id, input.notebookId, title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, createdAt, updatedAt),
c.env.DB.prepare(
`INSERT INTO memo_contents (
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`
).bind(id, JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, createdAt, updatedAt),
c.env.DB.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
).bind(id, title, contentText, tags.join(" ")),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.create", "memo", id, {
notebookId: input.notebookId,
}),
]);
return c.json({ memo: await getMemoDetail(c.env.DB, id) }, 201);
});
app.post("/api/v1/memos/batch/move", zValidator("json", MoveMemosSchema), async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const input = c.req.valid("json");
const target = await getNotebook(c.env.DB, input.notebookId);
if (!target) {
return notFound(c, "Target notebook not found");
}
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
try {
const moved = await moveMemosToNotebook(c.env.DB, input.memoIds, input.notebookId, actor, actorLabel);
return c.json({ ok: true, moved });
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.post("/api/v1/memos/batch/delete", zValidator("json", DeleteMemosSchema), async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const input = c.req.valid("json");
const actor = getAuditActor(c);
try {
const deleted = await deleteMemosRecord(c.env.DB, c.env.RESOURCES, input.memoIds, Boolean(input.permanent), actor);
return c.json({ ok: true, deleted });
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.delete("/api/v1/memos/trash/empty", async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const actor = getAuditActor(c);
const deleted = await emptyTrashMemosRecord(c.env.DB, c.env.RESOURCES, actor);
return c.json({ ok: true, deleted });
});
app.get("/api/v1/memos/:id", async (c) => {
const denied = requireScopes(c, "read:memos");
if (denied) {
return denied;
}
const includeDeleted = c.req.query("includeDeleted") === "1";
const memo = await getMemoDetail(c.env.DB, c.req.param("id"), includeDeleted);
if (!memo) {
return notFound(c, "Memo not found");
}
return c.json({ memo });
});
app.get("/api/v1/memos/:id/revisions", async (c) => {
const denied = requireScopes(c, "read:memos");
if (denied) {
return denied;
}
const memoId = c.req.param("id");
const memo = await getMemoDetail(c.env.DB, memoId);
if (!memo) {
return notFound(c, "Memo not found");
}
const limit = clampNumber(Number(c.req.query("limit") ?? 50), 1, 100);
const rows = await c.env.DB.prepare(
`SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown,
content_text, content_hash, created_by, created_at
FROM memo_revisions
WHERE memo_id = ?
ORDER BY revision DESC, created_at DESC
LIMIT ?`
)
.bind(memoId, limit)
.all<MemoRevisionRow>();
return c.json({ revisions: rows.results.map(mapMemoRevision) });
});
app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const memoId = c.req.param("id");
const revisionId = c.req.param("revisionId");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const current = await getMemoDetailRow(c.env.DB, memoId);
if (!current) {
return notFound(c, "Memo not found");
}
const revision = await getMemoRevisionRow(c.env.DB, memoId, revisionId);
if (!revision) {
return notFound(c, "Memo revision not found");
}
const tags = parseJsonArray(revision.tags_json);
const contentJson = parseDoc(revision.content_json);
const contentMarkdown = revision.content_markdown || docToMarkdown(contentJson);
const contentText = revision.content_text || docToText(contentJson);
const title = normalizeMemoTitle(revision.title);
const excerpt = createExcerpt(contentText);
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const nextRevision = current.revision + 1;
const now = isoNow();
await c.env.DB.batch([
createMemoRevisionStatement(c.env.DB, current, actorLabel, now),
c.env.DB.prepare(
`UPDATE memos
SET title = ?, excerpt = ?, tags_json = ?, updated_by = ?, updated_at = ?
WHERE id = ? AND is_deleted = 0`
).bind(title, excerpt, JSON.stringify(tags), actorLabel, now, memoId),
c.env.DB.prepare(
`UPDATE memo_contents
SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?,
revision = ?, updated_at = ?
WHERE memo_id = ?`
).bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, now, memoId),
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memoId),
c.env.DB.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
).bind(memoId, title, contentText, tags.join(" ")),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.revision_restore", "memo", memoId, {
revisionId,
restoredRevision: revision.revision,
revision: nextRevision,
}),
]);
return c.json({ memo: await getMemoDetail(c.env.DB, memoId) });
});
app.get("/api/v1/resources", async (c) => {
const denied = requireScopes(c, "read:resources");
if (denied) {
return denied;
}
const limit = clampNumber(Number(c.req.query("limit") ?? 500), 1, 500);
const [rows, stats] = await Promise.all([
c.env.DB.prepare(
`SELECT r.id, r.memo_id, r.original_memo_id, r.bucket_name, r.object_key, r.kind,
r.mime_type, r.filename, r.byte_size, r.sha256, r.width, r.height,
r.created_at, r.updated_at, m.title AS memo_title, m.excerpt AS memo_excerpt,
m.is_deleted AS memo_is_deleted
FROM resources r
LEFT JOIN memos m ON m.id = r.memo_id
WHERE r.is_deleted = 0
ORDER BY r.created_at DESC
LIMIT ?`
)
.bind(limit)
.all<ResourceListRow>(),
c.env.DB.prepare(
`SELECT COUNT(*) AS total_count,
COALESCE(SUM(byte_size), 0) AS total_bytes,
COALESCE(SUM(CASE WHEN kind = 'image' THEN 1 ELSE 0 END), 0) AS image_count,
COALESCE(SUM(CASE WHEN kind = 'attachment' THEN 1 ELSE 0 END), 0) AS attachment_count
FROM resources
WHERE is_deleted = 0`
).first<ResourceStatsRow>(),
]);
return c.json({
resources: rows.results.map(mapResourceListItem),
summary: mapResourceStorageSummary(stats),
});
});
app.post("/api/v1/memos/:id/resources", async (c) => {
const denied = requireScopes(c, "write:resources");
if (denied) {
return denied;
}
const memoId = c.req.param("id");
const memo = await getMemoDetail(c.env.DB, memoId);
if (!memo) {
return notFound(c, "Memo not found");
}
const form = await c.req.raw.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return badRequest(c, "Expected multipart form field named file.");
}
const actor = getAuditActor(c);
const bytes = new Uint8Array(await file.arrayBuffer());
const mimeType = file.type || "application/octet-stream";
let resource: Resource;
try {
resource = SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)
? await createImageResource(c, {
memoId,
filename: file.name,
mimeType,
bytes,
actor,
source: "upload",
})
: await createAttachmentResource(c, {
memoId,
filename: file.name,
mimeType,
bytes,
actor,
});
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
return c.json({ resource }, 201);
});
const createImageResource = async (
c: AppContext,
input: {
memoId: string;
filename: string;
mimeType: string;
bytes: Uint8Array;
actor: AuditActor;
source: "upload" | "mcp";
}
) => {
validateImageUpload(input.mimeType, input.bytes.byteLength);
const resourceId = createId("res");
const now = isoNow();
const processed = prepareImageForStorage({
bytes: input.bytes,
filename: input.filename,
mimeType: input.mimeType,
source: input.source,
});
const objectKey = `memos/${input.memoId}/${resourceId}${inferImageExtension(processed.filename, processed.mimeType)}`;
const bucketName = c.env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
const filename = normalizeFilename(processed.filename) || `${resourceId}${inferImageExtension(processed.filename, processed.mimeType)}`;
const checksum = await sha256Bytes(processed.bytes);
await c.env.RESOURCES.put(objectKey, processed.bytes, {
httpMetadata: {
contentType: processed.mimeType,
cacheControl: "private, max-age=3600",
},
customMetadata: {
memoId: input.memoId,
resourceId,
filename,
},
});
try {
await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO resources (
id, memo_id, bucket_name, object_key, kind, mime_type, filename,
byte_size, sha256, width, height, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, 'image', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
resourceId,
input.memoId,
bucketName,
objectKey,
processed.mimeType,
filename,
processed.bytes.byteLength,
checksum,
processed.width,
processed.height,
JSON.stringify(processed.metadata),
now,
now
),
auditStatement(c.env.DB, input.actor.actorType, input.actor.actorId, "resource.create", "resource", resourceId, {
memoId: input.memoId,
mimeType: processed.mimeType,
byteSize: processed.bytes.byteLength,
compressed: processed.compressed,
}),
]);
} catch (error) {
await c.env.RESOURCES.delete(objectKey);
throw error;
}
const resource = await getResourceRow(c.env.DB, resourceId);
if (!resource) {
throw new AppError("not_found", "Resource not found", 404);
}
return mapResource(resource);
};
const createAttachmentResource = async (
c: AppContext,
input: {
memoId: string;
filename: string;
mimeType: string;
bytes: Uint8Array;
actor: AuditActor;
}
) => {
validateAttachmentUpload(input.bytes.byteLength);
const resourceId = createId("res");
const now = isoNow();
const filename = normalizeFilename(input.filename) || resourceId;
const objectKey = `memos/${input.memoId}/${resourceId}`;
const bucketName = c.env.EDGE_EVER_R2_BUCKET_NAME?.trim() || DEFAULT_R2_BUCKET_NAME;
const checksum = await sha256Bytes(input.bytes);
await c.env.RESOURCES.put(objectKey, input.bytes, {
httpMetadata: {
contentType: input.mimeType,
cacheControl: "private, max-age=3600",
},
customMetadata: {
memoId: input.memoId,
resourceId,
filename,
},
});
try {
await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO resources (
id, memo_id, bucket_name, object_key, kind, mime_type, filename,
byte_size, sha256, width, height, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, 'attachment', ?, ?, ?, ?, NULL, NULL, ?, ?, ?)`
).bind(
resourceId,
input.memoId,
bucketName,
objectKey,
input.mimeType,
filename,
input.bytes.byteLength,
checksum,
JSON.stringify({ originalFilename: filename }),
now,
now
),
auditStatement(c.env.DB, input.actor.actorType, input.actor.actorId, "resource.create", "resource", resourceId, {
memoId: input.memoId,
mimeType: input.mimeType,
byteSize: input.bytes.byteLength,
}),
]);
} catch (error) {
await c.env.RESOURCES.delete(objectKey);
throw error;
}
const resource = await getResourceRow(c.env.DB, resourceId);
if (!resource) {
throw new AppError("not_found", "Resource not found", 404);
}
return mapResource(resource);
};
const validateImageUpload = (mimeType: string, size: number) => {
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
throw new AppError("unsupported_media_type", "Only PNG, JPEG, GIF, WebP and AVIF images are supported.", 415);
}
if (size <= 0 || size > MAX_IMAGE_UPLOAD_BYTES) {
throw new AppError("upload_too_large", "Image must be between 1 byte and 50 MB.", 413);
}
};
const validateAttachmentUpload = (size: number) => {
if (size <= 0 || size > MAX_ATTACHMENT_UPLOAD_BYTES) {
throw new AppError("upload_too_large", "Attachment must be between 1 byte and 50 MB.", 413);
}
};
type PreparedImage = {
bytes: Uint8Array;
mimeType: string;
filename: string;
width: number | null;
height: number | null;
compressed: boolean;
metadata: Record<string, unknown>;
};
const prepareImageForStorage = (input: {
bytes: Uint8Array;
filename: string;
mimeType: string;
source: "upload" | "mcp";
}): PreparedImage => ({
bytes: input.bytes,
mimeType: input.mimeType,
filename: input.filename,
width: null,
height: null,
compressed: false,
metadata: {
source: input.source,
originalFilename: normalizeFilename(input.filename) || null,
originalMimeType: input.mimeType,
originalByteSize: input.bytes.byteLength,
compression: "disabled",
},
});
app.get("/api/v1/resources/:id/blob", async (c) => {
const denied = requireScopes(c, "read:resources");
if (denied) {
return denied;
}
const resource = await getResourceRow(c.env.DB, c.req.param("id"));
if (!resource) {
return notFound(c, "Resource not found");
}
const object = await c.env.RESOURCES.get(resource.object_key);
if (!object) {
return notFound(c, "Resource object not found");
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("Content-Type", resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream");
headers.set("Cache-Control", headers.get("Cache-Control") ?? "private, max-age=3600");
headers.set("Content-Length", String(object.size));
headers.set("Content-Disposition", contentDispositionInline(resource.filename));
headers.set("X-Content-Type-Options", "nosniff");
return new Response(object.body, { headers });
});
app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const id = c.req.param("id");
const input = c.req.valid("json");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const current = await getMemoDetailRow(c.env.DB, id);
if (!current) {
return notFound(c, "Memo not found");
}
if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) {
return c.json(
{
error: {
code: "revision_conflict",
message: "Memo was updated elsewhere. Reload before saving.",
details: {
expectedRevision: input.expectedRevision,
currentRevision: current.revision,
},
},
},
409
);
}
const isPinned = input.isPinned ?? Boolean(current.is_pinned);
const hasContentUpdate =
input.notebookId !== undefined ||
input.title !== undefined ||
input.contentJson !== undefined ||
input.contentMarkdown !== undefined ||
input.tags !== undefined ||
input.createdAt !== undefined ||
input.updatedAt !== undefined;
const now = isoNow();
const updatedAt = input.updatedAt ?? now;
if (!hasContentUpdate) {
if (input.isPinned === undefined || isPinned === Boolean(current.is_pinned)) {
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
}
await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE memos
SET is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
WHERE id = ? AND is_deleted = 0`
).bind(isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
auditStatement(c.env.DB, actor.actorType, actor.actorId, isPinned ? "memo.pin" : "memo.unpin", "memo", id, {}),
]);
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
}
const currentContentJson = JSON.parse(current.content_json) as TiptapDoc;
const contentJson = input.contentJson
? (input.contentJson as TiptapDoc)
: input.contentMarkdown !== undefined
? markdownToDoc(input.contentMarkdown)
: currentContentJson;
const contentMarkdown =
input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson);
const contentText = docToText(contentJson);
const title =
input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title);
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
const excerpt = createExcerpt(contentText);
const notebookId = input.notebookId ?? current.notebook_id;
const nextRevision = current.revision + 1;
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const revisionStatements = (await shouldSnapshotMemoRevision(c.env.DB, current, title, JSON.stringify(tags), contentHash, updatedAt))
? [createMemoRevisionStatement(c.env.DB, current, actorLabel, updatedAt)]
: [];
await c.env.DB.batch([
...revisionStatements,
c.env.DB.prepare(
`UPDATE memos
SET notebook_id = ?, title = ?, excerpt = ?, tags_json = ?, is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
WHERE id = ? AND is_deleted = 0`
).bind(notebookId, title, excerpt, JSON.stringify(tags), isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
c.env.DB.prepare(
`UPDATE memo_contents
SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?,
revision = ?, updated_at = ?, created_at = COALESCE(?, created_at)
WHERE memo_id = ?`
).bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, updatedAt, input.createdAt ?? null, id),
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
c.env.DB.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
).bind(id, title, contentText, tags.join(" ")),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.update", "memo", id, {
revision: nextRevision,
}),
]);
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
});
app.delete("/api/v1/memos/:id", async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const id = c.req.param("id");
const actor = getAuditActor(c);
const permanent = c.req.query("permanent") === "1";
const now = isoNow();
if (permanent) {
const current = await getMemoDetailRow(c.env.DB, id, true);
if (!current || current.is_deleted === 0) {
return notFound(c, "Memo not found in trash");
}
const resources = await getResourceRowsForMemo(c.env.DB, id);
if (resources.length > 0) {
await c.env.RESOURCES.delete(resources.map((resource) => resource.object_key));
}
await c.env.DB.batch([
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
c.env.DB.prepare(`DELETE FROM resources WHERE memo_id = ?`).bind(id),
c.env.DB.prepare(`DELETE FROM memo_revisions WHERE memo_id = ?`).bind(id),
c.env.DB.prepare(`DELETE FROM memo_contents WHERE memo_id = ?`).bind(id),
c.env.DB.prepare(`DELETE FROM memos WHERE id = ? AND is_deleted = 1`).bind(id),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.delete_permanent", "memo", id, {}),
]);
return c.json({ ok: true });
}
await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE memos
SET is_deleted = 1, deleted_at = ?, updated_at = ?
WHERE id = ? AND is_deleted = 0`
).bind(now, now, id),
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.delete", "memo", id, {}),
]);
return c.json({ ok: true });
});
app.post("/api/v1/memos/:id/restore", async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const id = c.req.param("id");
const actor = getAuditActor(c);
const current = await getMemoDetailRow(c.env.DB, id, true);
if (!current || current.is_deleted === 0) {
return notFound(c, "Memo not found in trash");
}
const tags = parseJsonArray(current.tags_json);
const now = isoNow();
await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE memos
SET is_deleted = 0, deleted_at = NULL, updated_at = ?
WHERE id = ? AND is_deleted = 1`
).bind(now, id),
c.env.DB.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
c.env.DB.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
).bind(id, current.title, current.content_text, tags.join(" ")),
auditStatement(c.env.DB, actor.actorType, actor.actorId, "memo.restore", "memo", id, {}),
]);
return c.json({ memo: await getMemoDetail(c.env.DB, id) });
});
app.post("/api/v1/memos/merge", zValidator("json", MergeMemosSchema), async (c) => {
const denied = requireScopes(c, "write:memos");
if (denied) {
return denied;
}
const input = c.req.valid("json");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
try {
const memo = await mergeMemosRecord(c.env.DB, input, actor, actorLabel);
return c.json({ memo }, 201);
} catch (error) {
if (error instanceof AppError) {
return apiError(c, error.code, error.message, error.status);
}
throw error;
}
});
app.get("/mcp", (c) =>
c.json({
name: "EdgeEver MCP endpoint",
status: "ready",
transport: "streamable-http-jsonrpc",
auth: "Authorization: Bearer <api-token>",
restBasePath: "/api/v1",
})
);
app.post("/mcp", async (c) => {
let payload: unknown;
try {
payload = await c.req.json();
} catch {
return c.json(jsonRpcError(null, -32700, "Parse error"), 400);
}
if (Array.isArray(payload)) {
if (payload.length === 0) {
return c.json(jsonRpcError(null, -32600, "Invalid Request"), 400);
}
const results = await Promise.all(payload.map((request) => handleMcpMessage(c, request)));
const responses = results.filter((result): result is JsonRpcHandlerResult => Boolean(result));
const bodies = responses.map((response) => response.body);
if (bodies.length === 0) {
return new Response(null, { status: 204 });
}
return c.json(bodies, Math.max(...responses.map((response) => response.status)) as 200);
}
const result = await handleMcpMessage(c, payload);
if (!result) {
return new Response(null, { status: 204 });
}
return c.json(result.body, result.status as 200);
});
app.notFound((c) =>
c.json(
{
error: {
code: "not_found",
message: "Route not found",
},
},
404
)
);
export default app;
type JsonRpcRequest = {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: unknown;
};
type JsonRpcId = string | number | null;
type JsonRpcHandlerResult = {
body: unknown;
status: number;
};
class AppError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status = 400) {
super(message);
this.name = "AppError";
this.code = code;
this.status = status;
}
}
const MCP_PROTOCOL_VERSION = "2024-11-05";
const handleMcpMessage = async (c: AppContext, payload: unknown): Promise<JsonRpcHandlerResult | null> => {
const request = payload as JsonRpcRequest;
const id = getJsonRpcId(payload);
const isNotification =
payload &&
typeof payload === "object" &&
!("id" in payload) &&
typeof (payload as JsonRpcRequest).method === "string";
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
return { body: jsonRpcError(id, -32600, "Invalid Request"), status: 400 };
}
if (request.method === "notifications/initialized" && isNotification) {
return null;
}
if (request.method === "initialize") {
return {
body: jsonRpcResult(request.id ?? null, {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: {
listChanged: false,
},
},
serverInfo: {
name: "edgeever",
version: "0.1.0",
},
instructions:
"Use scoped EdgeEver API tokens. Prefer read-only scopes for search/list/get tools and grant write scopes only to agents that modify notes.",
}),
status: 200,
};
}
const auth = await authenticateRequest(c, true);
if (!auth) {
return { body: jsonRpcError(request.id ?? null, -32001, "Authentication required"), status: 401 };
}
c.set("auth", auth);
if (request.method === "tools/list") {
return {
body: jsonRpcResult(request.id ?? null, {
tools: MCP_TOOLS,
}),
status: 200,
};
}
if (request.method === "tools/call") {
const params = asRecord(request.params);
const name = getOptionalString(params.name);
if (!name) {
return { body: jsonRpcError(request.id ?? null, -32602, "Tool name is required"), status: 400 };
}
try {
const result = await callMcpTool(c, auth, name, asRecord(params.arguments));
return {
body: jsonRpcResult(request.id ?? null, {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
isError: false,
}),
status: 200,
};
} catch (error) {
const mapped = mapMcpToolError(error);
return {
body: jsonRpcError(request.id ?? null, mapped.rpcCode, mapped.message, mapped.data),
status: mapped.status,
};
}
}
if (isNotification) {
return null;
}
return { body: jsonRpcError(request.id ?? null, -32601, "Method not found"), status: 404 };
};
const mapMcpToolError = (error: unknown) => {
if (error instanceof AppError) {
const rpcCode =
error.status === 401
? -32001
: error.status === 403
? -32003
: error.status === 404
? -32004
: error.status === 409
? -32009
: -32602;
return {
rpcCode,
status: error.status,
message: error.message,
data: {
code: error.code,
},
};
}
return {
rpcCode: -32000,
status: 400,
message: error instanceof Error ? error.message : "Tool call failed",
data: undefined,
};
};
const MCP_TOOLS = [
{
name: "search_memos",
description: "Search active EdgeEver memos by text, tag, or notebook.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
query: { type: "string" },
notebookId: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 50 },
},
},
},
{
name: "list_memos",
description: "List active EdgeEver memos with pagination. Use includeContent when full Markdown is needed.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
notebookId: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 100 },
offset: { type: "integer", minimum: 0 },
includeContent: { type: "boolean" },
},
},
},
{
name: "get_memo",
description: "Read a memo with Markdown content.",
inputSchema: {
type: "object",
required: ["memoId"],
additionalProperties: false,
properties: {
memoId: { type: "string" },
},
},
},
{
name: "create_memo",
description: "Create a memo in a notebook.",
inputSchema: {
type: "object",
required: ["notebookId"],
additionalProperties: false,
properties: {
notebookId: { type: "string" },
title: { type: "string" },
contentMarkdown: { type: "string" },
tags: { type: "array", items: { type: "string" } },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
},
{
name: "update_memo",
description: "Update memo title, Markdown, tags, notebook, or pinned state.",
inputSchema: {
type: "object",
required: ["memoId"],
additionalProperties: false,
properties: {
memoId: { type: "string" },
title: { type: "string" },
isPinned: { type: "boolean" },
contentMarkdown: { type: "string" },
tags: { type: "array", items: { type: "string" } },
notebookId: { type: "string" },
expectedRevision: { type: "integer", minimum: 0 },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
},
{
name: "upload_memo_image",
description:
"Upload a base64-encoded image resource to a memo and return Markdown that can be inserted into memo content. Images are stored as provided; server-side compression is disabled to avoid Cloudflare Images quota usage.",
inputSchema: {
type: "object",
required: ["memoId", "mimeType", "dataBase64"],
additionalProperties: false,
properties: {
memoId: { type: "string" },
filename: { type: "string" },
mimeType: { type: "string", enum: ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"] },
dataBase64: { type: "string" },
alt: { type: "string" },
},
},
},
{
name: "move_memos",
description: "Move one or more active memos to another notebook.",
inputSchema: {
type: "object",
required: ["memoIds", "notebookId"],
additionalProperties: false,
properties: {
memoIds: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } },
notebookId: { type: "string" },
},
},
},
{
name: "merge_memos",
description: "Merge multiple active memos into a new memo and soft-delete the sources.",
inputSchema: {
type: "object",
required: ["memoIds"],
additionalProperties: false,
properties: {
memoIds: { type: "array", minItems: 2, maxItems: 50, items: { type: "string" } },
notebookId: { type: "string" },
title: { type: "string" },
},
},
},
{
name: "move_notebook",
description: "Move a notebook under another notebook or root and update its sort order.",
inputSchema: {
type: "object",
required: ["notebookId"],
additionalProperties: false,
properties: {
notebookId: { type: "string" },
parentId: { type: ["string", "null"] },
sortOrder: { type: "integer" },
},
},
},
{
name: "create_notebook",
description: "Create a notebook at the root or under another notebook.",
inputSchema: {
type: "object",
required: ["name"],
additionalProperties: false,
properties: {
name: { type: "string", minLength: 1, maxLength: 80 },
parentId: { type: ["string", "null"] },
sortOrder: { type: "integer" },
},
},
},
{
name: "list_notebooks",
description: "List active notebooks.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
{
name: "list_tags",
description: "List tags and memo counts.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
];
const callMcpTool = async (
c: AppContext,
auth: AuthContext,
name: string,
args: Record<string, unknown>
) => {
switch (name) {
case "search_memos": {
assertScope(auth, "read:memos");
return {
memos: await searchMemoSummaries(c.env.DB, {
query: getOptionalString(args.query),
notebookId: getOptionalString(args.notebookId),
limit: clampNumber(Number(args.limit ?? 20), 1, 50),
}),
};
}
case "list_memos": {
assertScope(auth, "read:memos");
return await listMemosForMcp(c.env.DB, {
notebookId: getOptionalString(args.notebookId),
limit: clampNumber(Number(args.limit ?? 50), 1, 100),
offset: clampNumber(Number(args.offset ?? 0), 0, 100_000),
includeContent: args.includeContent === true,
});
}
case "get_memo": {
assertScope(auth, "read:memos");
const memoId = getRequiredString(args.memoId, "memoId");
const memo = await getMemoDetail(c.env.DB, memoId);
if (!memo) {
throw new Error("Memo not found");
}
return { memo };
}
case "create_memo": {
assertScope(auth, "write:memos");
const notebookId = getRequiredString(args.notebookId, "notebookId");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const memo = await createMemoRecord(c.env.DB, {
notebookId,
title: getOptionalString(args.title) ?? undefined,
contentMarkdown: getOptionalString(args.contentMarkdown) ?? "",
tags: getOptionalStringArray(args.tags),
createdAt: getOptionalString(args.createdAt) ?? undefined,
updatedAt: getOptionalString(args.updatedAt) ?? undefined,
}, actor, actorLabel);
return { memo };
}
case "update_memo": {
assertScope(auth, "write:memos");
const memoId = getRequiredString(args.memoId, "memoId");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const result = await updateMemoRecord(
c.env.DB,
memoId,
{
expectedRevision:
typeof args.expectedRevision === "number" && Number.isInteger(args.expectedRevision)
? args.expectedRevision
: undefined,
notebookId: getOptionalString(args.notebookId) ?? undefined,
title: getOptionalString(args.title) ?? undefined,
isPinned: typeof args.isPinned === "boolean" ? args.isPinned : undefined,
contentMarkdown: getOptionalString(args.contentMarkdown) ?? undefined,
tags: Array.isArray(args.tags) ? getOptionalStringArray(args.tags) : undefined,
createdAt: getOptionalString(args.createdAt) ?? undefined,
updatedAt: getOptionalString(args.updatedAt) ?? undefined,
},
actor,
actorLabel
);
if ("error" in result) {
throw new Error(result.message);
}
return { memo: result.memo };
}
case "upload_memo_image": {
assertScope(auth, "write:resources");
const memoId = getRequiredString(args.memoId, "memoId");
const memo = await getMemoDetail(c.env.DB, memoId);
if (!memo) {
throw new AppError("not_found", "Memo not found", 404);
}
const mimeType = getRequiredString(args.mimeType, "mimeType");
const filename = getOptionalString(args.filename) ?? `image${inferImageExtension("", mimeType)}`;
const bytes = await decodeBase64ImageData(getRequiredString(args.dataBase64, "dataBase64"));
const resource = await createImageResource(c, {
memoId,
filename,
mimeType,
bytes,
actor: getAuditActor(c),
source: "mcp",
});
const alt = getOptionalString(args.alt) ?? normalizeFilename(filename) ?? "image";
return {
resource,
markdownImage: `![${escapeMarkdownImageAlt(alt)}](${resource.url})`,
};
}
case "move_memos": {
assertScope(auth, "write:memos");
const notebookId = getRequiredString(args.notebookId, "notebookId");
const memoIds = getRequiredStringArray(args.memoIds, "memoIds");
const target = await getNotebook(c.env.DB, notebookId);
if (!target) {
throw new AppError("not_found", "Target notebook not found", 404);
}
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const moved = await moveMemosToNotebook(c.env.DB, memoIds, notebookId, actor, actorLabel);
return { ok: true, moved };
}
case "merge_memos": {
assertScope(auth, "write:memos");
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
const memo = await mergeMemosRecord(
c.env.DB,
{
memoIds: getRequiredStringArray(args.memoIds, "memoIds"),
notebookId: getOptionalString(args.notebookId) ?? undefined,
title: getOptionalString(args.title) ?? undefined,
},
actor,
actorLabel
);
return { memo };
}
case "move_notebook": {
assertScope(auth, "write:notebooks");
const actor = getAuditActor(c);
const notebook = await updateNotebookRecord(
c.env.DB,
getRequiredString(args.notebookId, "notebookId"),
{
parentId: args.parentId === null ? null : getOptionalString(args.parentId) ?? undefined,
sortOrder: typeof args.sortOrder === "number" && Number.isInteger(args.sortOrder) ? args.sortOrder : undefined,
},
actor
);
return { notebook };
}
case "create_notebook": {
assertScope(auth, "write:notebooks");
const actor = getAuditActor(c);
const name = getRequiredString(args.name, "name");
if (name.length > 80) {
throw new AppError("invalid_params", "name must be at most 80 characters", 400);
}
const notebook = await createNotebookRecord(
c.env.DB,
{
name,
parentId: args.parentId === null ? null : getOptionalString(args.parentId) ?? undefined,
sortOrder: typeof args.sortOrder === "number" && Number.isInteger(args.sortOrder) ? args.sortOrder : undefined,
},
actor
);
return { notebook };
}
case "list_notebooks": {
assertScope(auth, "read:notebooks");
return { notebooks: await listNotebooks(c.env.DB) };
}
case "list_tags": {
assertScope(auth, "read:tags");
return { tags: await listTagSummaries(c.env.DB) };
}
default:
throw new Error(`Unknown tool: ${name}`);
}
};
const jsonRpcResult = (id: JsonRpcId, result: unknown) => ({
jsonrpc: "2.0",
id,
result,
});
const jsonRpcError = (id: JsonRpcId, code: number, message: string, data?: unknown) => ({
jsonrpc: "2.0",
id,
error: {
code,
message,
...(data === undefined ? {} : { data }),
},
});
const getJsonRpcId = (request: unknown): JsonRpcId => {
if (!request || typeof request !== "object" || !("id" in request)) {
return null;
}
const id = (request as { id?: unknown }).id;
return typeof id === "string" || typeof id === "number" || id === null ? id : null;
};
const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const getOptionalString = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null);
const getRequiredString = (value: unknown, name: string) => {
const parsed = getOptionalString(value);
if (!parsed) {
throw new AppError("invalid_params", `${name} is required`, 400);
}
return parsed;
};
const getOptionalStringArray = (value: unknown) =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const getRequiredStringArray = (value: unknown, name: string) => {
const items = getOptionalStringArray(value);
if (items.length === 0) {
throw new AppError("invalid_params", `${name} must include at least one item`, 400);
}
return items;
};
const decodeBase64ImageData = async (value: string) => {
const [, dataUrlPayload] = value.match(/^data:[^;]+;base64,(.+)$/i) ?? [];
const base64 = (dataUrlPayload ?? value).replace(/\s/g, "");
if (!base64) {
throw new AppError("invalid_params", "dataBase64 is required", 400);
}
try {
const response = await fetch("data:application/octet-stream;base64," + base64);
if (!response.ok) {
throw new Error("failed to decode base64");
}
return new Uint8Array(await response.arrayBuffer());
} catch (error) {
throw new AppError("invalid_params", "dataBase64 must be valid base64 image data: " + (error as Error).message, 400);
}
};
const escapeMarkdownImageAlt = (value: string) => value.replace(/[\\[\]]/g, "\\$&");
const isAuthRequired = async (env: Bindings) => {
if (env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim()) {
return true;
}
const user = await env.DB.prepare(`SELECT id FROM users WHERE is_disabled = 0 LIMIT 1`).first<{ id: string }>();
return Boolean(user);
};
const verifyLogin = async (env: Bindings, username: string, password: string): Promise<UserRow | null> => {
const normalizedUsername = username.trim();
const existingUser = await getUserByUsername(env.DB, normalizedUsername);
if (existingUser) {
return (await verifyPassword(password, existingUser.password_hash)) ? existingUser : null;
}
const configuredHash = env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim();
if (!configuredHash) {
return null;
}
const configuredUsername = env.EDGE_EVER_AUTH_USERNAME?.trim() || "admin";
if (normalizedUsername !== configuredUsername || !(await verifyPassword(password, configuredHash))) {
return null;
}
const now = isoNow();
const userId = createId("usr");
const passwordHash = await hashPassword(password);
await env.DB.prepare(
`INSERT OR IGNORE INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(userId, normalizedUsername, passwordHash, normalizedUsername, now, now)
.run();
return getUserByUsername(env.DB, normalizedUsername);
};
const getUserByUsername = async (db: D1Database, username: string) =>
db
.prepare(
`SELECT id, username, password_hash, display_name, is_disabled
FROM users
WHERE username = ? AND is_disabled = 0`
)
.bind(username)
.first<UserRow>();
const createSession = async (c: AppContext, user: UserRow) => {
const token = randomToken(SESSION_TOKEN_BYTES);
const id = createId("sess");
const now = isoNow();
const maxAge = getSessionMaxAge(c.env);
const expiresAt = new Date(Date.now() + maxAge * 1000).toISOString();
const ip = c.req.header("CF-Connecting-IP");
const ipHash = ip ? await sha256(ip) : null;
await c.env.DB.prepare(
`INSERT INTO sessions (
id, user_id, token_hash, user_agent, ip_hash, expires_at, created_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
id,
user.id,
await sha256(token),
c.req.header("User-Agent") ?? null,
ipHash,
expiresAt,
now,
now
)
.run();
return { id, token, maxAge };
};
const setSessionCookie = (c: AppContext, token: string, maxAge: number) => {
setCookie(c, SESSION_COOKIE, token, {
httpOnly: true,
secure: new URL(c.req.url).protocol === "https:",
sameSite: "Lax",
path: "/",
maxAge,
});
};
const revokeSession = async (db: D1Database, token: string) => {
await db
.prepare(`UPDATE sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`)
.bind(isoNow(), await sha256(token))
.run();
};
const authenticateRequest = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
const bearerAuth = await authenticateBearerToken(c, touch);
if (bearerAuth) {
return bearerAuth;
}
return authenticateSession(c, touch);
};
const authenticateBearerToken = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
const token = getBearerToken(c);
if (!token) {
return null;
}
const row = await c.env.DB.prepare(
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at
FROM api_tokens
WHERE token_hash = ?
AND is_revoked = 0
AND (expires_at IS NULL OR expires_at > ?)`
)
.bind(await sha256(token), isoNow())
.first<ApiTokenRow>();
if (!row) {
return null;
}
if (touch) {
await c.env.DB.prepare(`UPDATE api_tokens SET last_used_at = ? WHERE id = ?`).bind(isoNow(), row.id).run();
}
return {
kind: "agent",
actorType: "agent",
actorId: row.id,
username: row.name,
displayName: row.name,
scopes: parseJsonArray(row.scopes_json),
tokenId: row.id,
};
};
const authenticateSession = async (c: AppContext, touch: boolean): Promise<AuthContext | null> => {
const token = getCookie(c, SESSION_COOKIE);
if (!token) {
return null;
}
const row = await c.env.DB.prepare(
`SELECT s.id, s.user_id, u.username, u.display_name, s.expires_at
FROM sessions s
INNER JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
AND s.revoked_at IS NULL
AND s.expires_at > ?
AND u.is_disabled = 0`
)
.bind(await sha256(token), isoNow())
.first<SessionRow>();
if (!row) {
return null;
}
if (touch) {
await c.env.DB.prepare(`UPDATE sessions SET last_seen_at = ? WHERE id = ?`).bind(isoNow(), row.id).run();
}
return {
kind: "user",
actorType: "user",
actorId: row.user_id,
username: row.username,
displayName: row.display_name,
scopes: [],
sessionId: row.id,
};
};
const getBearerToken = (c: AppContext) => {
const authorization = c.req.header("Authorization");
if (!authorization) {
return null;
}
const [scheme, token] = authorization.split(/\s+/, 2);
return scheme.toLowerCase() === "bearer" && token ? token : null;
};
const getAuditActor = (c: AppContext) => {
const auth = c.get("auth");
return {
actorType: auth?.actorType ?? "user",
actorId: auth?.actorId ?? null,
};
};
const getActorLabel = (c: AppContext) => {
const auth = c.get("auth");
return auth?.actorId ? `${auth.actorType}:${auth.actorId}` : auth?.username ?? "user";
};
const requireUser = (c: AppContext) => {
const auth = c.get("auth");
if (auth?.kind === "user") {
return null;
}
return forbidden(c, "Only an interactive user session can manage this resource.");
};
const requireScopes = (c: AppContext, ...scopes: TokenScope[]) => {
const auth = c.get("auth");
if (!auth) {
return unauthorized(c, "Authentication required.");
}
if (hasScopes(auth, scopes)) {
return null;
}
return forbidden(c, `Missing required scope: ${scopes.join(", ")}`);
};
const assertScope = (auth: AuthContext, scope: TokenScope) => {
if (!hasScopes(auth, [scope])) {
throw new AppError("forbidden", `Missing required scope: ${scope}`, 403);
}
};
const hasScopes = (auth: AuthContext, scopes: TokenScope[]) => {
if (auth.kind === "user") {
return true;
}
return scopes.every((scope) => auth.scopes.includes(scope));
};
const normalizeTokenScopes = (scopes: string[]) => {
const normalized = Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
if (normalized.some((scope) => !isTokenScope(scope))) {
return null;
}
return normalized as TokenScope[];
};
const isTokenScope = (scope: string): scope is TokenScope =>
(ALL_TOKEN_SCOPES as readonly string[]).includes(scope);
const getSessionMaxAge = (env: Bindings) => {
const days = clampNumber(Number(env.EDGE_EVER_SESSION_TTL_DAYS ?? DEFAULT_SESSION_TTL_DAYS), 1, 90);
return days * 24 * 60 * 60;
};
const hashPassword = async (password: string) => {
const salt = crypto.getRandomValues(new Uint8Array(PASSWORD_SALT_BYTES));
const hash = await derivePasswordHash(password, salt, PASSWORD_HASH_ITERATIONS);
return [
PASSWORD_HASH_ALGORITHM,
PASSWORD_HASH_ITERATIONS,
base64UrlEncode(salt),
base64UrlEncode(hash),
].join("$");
};
const verifyPassword = async (password: string, passwordHash: string) => {
const [algorithm, iterationsRaw, saltRaw, hashRaw] = passwordHash.split("$");
const iterations = Number(iterationsRaw);
if (
algorithm !== PASSWORD_HASH_ALGORITHM ||
!Number.isInteger(iterations) ||
iterations < 100_000 ||
!saltRaw ||
!hashRaw
) {
return false;
}
try {
const expected = base64UrlDecode(hashRaw);
const salt = base64UrlDecode(saltRaw);
const actual = await derivePasswordHash(password, salt, iterations);
return timingSafeEqual(actual, expected);
} catch {
return false;
}
};
const derivePasswordHash = async (password: string, salt: Uint8Array, iterations: number) => {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [
"deriveBits",
]);
const saltBuffer = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
salt: saltBuffer,
iterations,
},
key,
PASSWORD_HASH_BYTES * 8
);
return new Uint8Array(bits);
};
const randomToken = (bytes: number) => {
const token = crypto.getRandomValues(new Uint8Array(bytes));
return base64UrlEncode(token);
};
const base64UrlEncode = (bytes: Uint8Array) => {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};
const base64UrlDecode = (value: string) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
};
const timingSafeEqual = (left: Uint8Array, right: Uint8Array) => {
const length = Math.max(left.length, right.length);
let diff = left.length ^ right.length;
for (let index = 0; index < length; index += 1) {
diff |= (left[index % left.length] ?? 0) ^ (right[index % right.length] ?? 0);
}
return diff === 0;
};
const mapNotebook = (row: NotebookRow): Notebook => ({
id: row.id,
parentId: row.parent_id,
name: row.name,
slug: row.slug,
icon: row.icon,
color: row.color,
sortOrder: row.sort_order,
memoCount: row.memo_count ?? 0,
lastMemoUpdatedAt: row.last_memo_updated_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const notebookSelectSql = (tail: string) => `
SELECT n.id,
n.parent_id,
n.name,
n.slug,
n.icon,
n.color,
n.sort_order,
COUNT(m.id) AS memo_count,
MAX(m.updated_at) AS last_memo_updated_at,
n.created_at,
n.updated_at
FROM notebooks n
LEFT JOIN memos m ON m.notebook_id = n.id AND m.is_deleted = 0
${tail}
`;
const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({
id: row.id,
notebookId: row.notebook_id,
title: row.title,
excerpt: row.excerpt,
tags: parseJsonArray(row.tags_json),
isPinned: Boolean(row.is_pinned),
isArchived: Boolean(row.is_archived),
isDeleted: Boolean(row.is_deleted),
revision: row.revision,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
});
const mapMemoDetail = (row: MemoDetailRow): MemoDetail => ({
...mapMemoSummary(row),
contentJson: parseDoc(row.content_json),
contentMarkdown: row.content_markdown,
contentText: row.content_text,
sourceMemoIds: parseJsonArray(row.source_memo_ids),
mergeSourceCount: row.merge_source_count,
mergedIntoMemoId: row.merged_into_memo_id,
});
const mapMemoRevision = (row: MemoRevisionRow): MemoRevision => ({
id: row.id,
memoId: row.memo_id,
revision: row.revision,
title: row.title,
tags: parseJsonArray(row.tags_json),
contentMarkdown: row.content_markdown,
contentText: row.content_text,
contentHash: row.content_hash,
createdBy: row.created_by,
createdAt: row.created_at,
});
const mapResource = (row: ResourceRow): Resource => ({
id: row.id,
memoId: row.memo_id,
originalMemoId: row.original_memo_id,
kind: row.kind,
mimeType: row.mime_type,
filename: row.filename,
byteSize: row.byte_size,
sha256: row.sha256,
width: row.width,
height: row.height,
createdAt: row.created_at,
updatedAt: row.updated_at,
url: `/api/v1/resources/${row.id}/blob`,
});
const mapResourceListItem = (row: ResourceListRow): ResourceListItem => ({
...mapResource(row),
memoTitle: row.memo_title,
memoExcerpt: row.memo_excerpt,
memoDeleted: Boolean(row.memo_is_deleted),
});
const mapResourceStorageSummary = (row: ResourceStatsRow | null): ResourceStorageSummary => ({
totalCount: row?.total_count ?? 0,
totalBytes: row?.total_bytes ?? 0,
imageCount: row?.image_count ?? 0,
attachmentCount: row?.attachment_count ?? 0,
});
const mapApiToken = (row: ApiTokenRow): ApiToken => ({
id: row.id,
name: row.name,
token: row.token_value,
scopes: parseJsonArray(row.scopes_json),
lastUsedAt: row.last_used_at,
expiresAt: row.expires_at,
isRevoked: Boolean(row.is_revoked),
createdAt: row.created_at,
});
const mapTagSummary = (row: TagSummaryRow): TagSummary => ({
name: row.name,
memoCount: row.memo_count,
updatedAt: row.updated_at,
});
const mapProfileSnapshot = (row: ProfileSnapshotRow): ProfileSnapshot => {
const profile = parseProfileJson(row.profile_json);
return {
id: row.id,
summary: profile.summary,
sections: profile.sections,
sourceMemoIds: parseJsonArray(row.source_memo_ids_json),
memoCount: row.memo_count,
model: row.model,
generatedAt: row.generated_at,
createdAt: row.created_at,
};
};
const getLatestProfileSnapshot = async (db: D1Database): Promise<ProfileSnapshot | null> => {
const row = await db
.prepare(
`SELECT id, profile_json, source_memo_ids_json, memo_count, model, generated_at, created_at
FROM profile_snapshots
ORDER BY created_at DESC
LIMIT 1`
)
.first<ProfileSnapshotRow>();
return row ? mapProfileSnapshot(row) : null;
};
const generateProfileSnapshot = async (
db: D1Database,
ai: Ai,
modelOverride: string | undefined,
actor: AuditActor
): Promise<ProfileSnapshot> => {
const memos = await listProfileSourceMemos(db);
if (memos.length === 0) {
throw new AppError("no_profile_sources", "At least one non-empty memo is required to generate a profile.", 400);
}
const model = modelOverride?.trim() || DEFAULT_PROFILE_MODEL;
const chunks = chunkProfileMemos(memos);
const chunkSummaries: string[] = [];
for (let index = 0; index < chunks.length; index += 1) {
const summary = await runAiText(ai, model, [
{
role: "system",
content:
"你是一个谨慎的个人知识管理分析助手。只基于用户笔记做事实归纳,不做心理诊断,不使用夸张或确定性人格判断。",
},
{
role: "user",
content:
"请把下面这组笔记压缩成画像证据摘要。保留主题、项目、偏好、能力、反复出现的问题和可引用的 memo_id。输出中文,控制在 900 字以内。\n\n" +
chunks[index],
},
]);
chunkSummaries.push(summary);
}
const profileText = await runAiText(ai, model, [
{
role: "system",
content:
"你是 EdgeEver 的人物画像生成器。必须输出严格 JSON,不要 Markdown。画像应可追溯、克制、尊重用户隐私。避免医学、心理诊断、职业定性和无法从笔记支持的结论。",
},
{
role: "user",
content:
`请基于这些证据摘要生成人物画像 JSON。可引用的 memo_id 只能来自证据摘要。JSON schema:
{
"summary": "一段 80-160 字中文总览",
"sections": [
{
"title": "关注主题",
"insights": [
{
"label": "短标签",
"description": "基于笔记的克制描述",
"confidence": "low|medium|high",
"evidence": { "memoIds": ["memo_x"], "note": "为什么引用这些笔记" }
}
]
}
]
}
请包含 4-6 个 section,优先使用这些 section 名称:关注主题、项目脉络、能力画像、偏好与原则、行为模式、近期动向。每个 section 2-5 条 insight。不要虚构 memo_id。\n\n证据摘要:\n` +
chunkSummaries.map((summary, index) => `【证据摘要 ${index + 1}】\n${summary}`).join("\n\n"),
},
]);
const profile = normalizeProfileOutput(profileText, memos.map((memo) => memo.id));
const id = createId("profile");
const now = isoNow();
const sourceMemoIds = Array.from(new Set(profile.sections.flatMap((section) => section.insights.flatMap((insight) => insight.evidence.memoIds))));
const finalSourceMemoIds = sourceMemoIds.length > 0 ? sourceMemoIds : memos.map((memo) => memo.id);
await db.batch([
db
.prepare(
`INSERT INTO profile_snapshots (
id, profile_json, source_memo_ids_json, memo_count, model, generated_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(id, JSON.stringify(profile), JSON.stringify(finalSourceMemoIds), memos.length, model, now, now),
auditStatement(db, actor.actorType, actor.actorId, "profile.generate", "profile", id, {
memoCount: memos.length,
model,
}),
]);
const row = await db
.prepare(
`SELECT id, profile_json, source_memo_ids_json, memo_count, model, generated_at, created_at
FROM profile_snapshots
WHERE id = ?`
)
.bind(id)
.first<ProfileSnapshotRow>();
if (!row) {
throw new AppError("not_found", "Profile snapshot not found after generation.", 404);
}
return mapProfileSnapshot(row);
};
const listProfileSourceMemos = async (db: D1Database): Promise<ProfileSourceMemoRow[]> => {
const rows = await db
.prepare(
`SELECT m.id, m.title, m.tags_json, m.updated_at, c.content_text
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.is_deleted = 0
AND length(trim(c.content_text)) >= 20
AND m.created_by <> 'system'
ORDER BY m.updated_at DESC
LIMIT ?`
)
.bind(PROFILE_MEMO_LIMIT)
.all<ProfileSourceMemoRow>();
return rows.results;
};
const chunkProfileMemos = (memos: ProfileSourceMemoRow[]) => {
const chunks: string[] = [];
let current = "";
for (const memo of memos) {
const tags = parseJsonArray(memo.tags_json);
const text = memo.content_text.replace(/\s+/g, " ").trim().slice(0, PROFILE_MEMO_TEXT_LIMIT);
const entry = [
`memo_id: ${memo.id}`,
`title: ${memo.title || DEFAULT_MEMO_TITLE}`,
`updated_at: ${memo.updated_at}`,
`tags: ${tags.join(", ") || "无"}`,
`content: ${text}`,
].join("\n");
const next = current ? `${current}\n\n---\n\n${entry}` : entry;
if (next.length > PROFILE_CHUNK_CHAR_LIMIT && current) {
chunks.push(current);
current = entry;
} else {
current = next;
}
}
if (current) {
chunks.push(current);
}
return chunks;
};
const runAiText = async (
ai: Ai,
model: string,
messages: { role: "system" | "user"; content: string }[]
): Promise<string> => {
let result: unknown;
try {
result = await ai.run(model, {
messages,
max_tokens: 1800,
temperature: 0.2,
});
} catch (error) {
throw new AppError(
"ai_request_failed",
error instanceof Error ? `Workers AI request failed: ${error.message}` : "Workers AI request failed.",
502
);
}
const response = extractAiText(result);
if (!response) {
throw new AppError("ai_response_invalid", "Workers AI returned an empty response.", 502);
}
return response;
};
const extractAiText = (value: unknown) => {
if (typeof value === "string") {
return value.trim();
}
if (!value || typeof value !== "object") {
return "";
}
const record = value as Record<string, unknown>;
if (typeof record.response === "string") {
return record.response.trim();
}
if (typeof record.result === "string") {
return record.result.trim();
}
return "";
};
const normalizeProfileOutput = (text: string, allowedMemoIds: string[]) => {
const parsed = parseJsonObject(extractJsonObject(text));
const allowed = new Set(allowedMemoIds);
const summary = typeof parsed.summary === "string" ? parsed.summary.trim().slice(0, 360) : "";
const rawSections = Array.isArray(parsed.sections) ? parsed.sections : [];
const sections: ProfileSection[] = rawSections
.map((section) => normalizeProfileSection(section, allowed))
.filter((section): section is ProfileSection => Boolean(section));
if (!summary || sections.length === 0) {
throw new AppError("ai_response_invalid", "Workers AI returned a profile that could not be parsed.", 502);
}
return {
summary,
sections: sections.slice(0, 6),
};
};
const normalizeProfileSection = (value: unknown, allowedMemoIds: Set<string>): ProfileSection | null => {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title.trim().slice(0, 40) : "";
const rawInsights = Array.isArray(record.insights) ? record.insights : [];
const insights = rawInsights
.map((insight) => normalizeProfileInsight(insight, allowedMemoIds))
.filter((insight): insight is ProfileSection["insights"][number] => Boolean(insight))
.slice(0, 5);
if (!title || insights.length === 0) {
return null;
}
return { title, insights };
};
const normalizeProfileInsight = (value: unknown, allowedMemoIds: Set<string>): ProfileSection["insights"][number] | null => {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const evidence = record.evidence && typeof record.evidence === "object" ? (record.evidence as Record<string, unknown>) : {};
const memoIds = (Array.isArray(evidence.memoIds) ? evidence.memoIds : [])
.filter((memoId): memoId is string => typeof memoId === "string" && allowedMemoIds.has(memoId))
.slice(0, 8);
const confidence = record.confidence === "low" || record.confidence === "high" ? record.confidence : "medium";
const label = typeof record.label === "string" ? record.label.trim().slice(0, 60) : "";
const description = typeof record.description === "string" ? record.description.trim().slice(0, 360) : "";
if (!label || !description) {
return null;
}
return {
label,
description,
confidence,
evidence: {
memoIds,
note: typeof evidence.note === "string" ? evidence.note.trim().slice(0, 160) : undefined,
},
};
};
const parseProfileJson = (json: string): Pick<ProfileSnapshot, "summary" | "sections"> => {
const value = parseJsonObject(json);
const summary = typeof value.summary === "string" ? value.summary : "";
const sections = Array.isArray(value.sections) ? (value.sections as ProfileSection[]) : [];
return { summary, sections };
};
const extractJsonObject = (text: string) => {
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
const candidate = fenced?.[1] ?? text;
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
return candidate;
}
return candidate.slice(start, end + 1);
};
const parseJsonObject = (json: string): Record<string, unknown> => {
try {
const value = JSON.parse(json);
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
} catch {
return {};
}
};
const getApiTokenRow = async (db: D1Database, id: string): Promise<ApiTokenRow | null> =>
db
.prepare(
`SELECT id, name, token_value, scopes_json, last_used_at, expires_at, is_revoked, created_at
FROM api_tokens
WHERE id = ?`
)
.bind(id)
.first<ApiTokenRow>();
const listNotebooks = async (db: D1Database): Promise<Notebook[]> => {
const rows = await db
.prepare(
notebookSelectSql(
`WHERE n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
)
)
.all<NotebookRow>();
return rows.results.map(mapNotebook);
};
const listTagSummaries = async (db: D1Database): Promise<TagSummary[]> => {
const rows = await db
.prepare(
`SELECT json_each.value AS name,
COUNT(DISTINCT m.id) AS memo_count,
MAX(m.updated_at) AS updated_at
FROM memos m, json_each(m.tags_json)
WHERE m.is_deleted = 0
AND trim(json_each.value) <> ''
GROUP BY json_each.value
ORDER BY lower(json_each.value) ASC`
)
.all<TagSummaryRow>();
return rows.results
.filter((row) => typeof row.name === "string" && row.name.trim())
.map(mapTagSummary);
};
const updateTagAcrossMemos = async (
db: D1Database,
oldTag: string,
nextTag: string | null,
actor: { actorType: "user" | "agent"; actorId: string | null },
actorLabel: string
) => {
const normalizedOld = normalizeTags([oldTag])[0];
const normalizedNext = nextTag === null ? null : normalizeTags([nextTag])[0];
if (!normalizedOld || normalizedOld === normalizedNext) {
return 0;
}
const rows = await db
.prepare(
`SELECT m.id, m.title, m.tags_json, c.content_text
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.is_deleted = 0
AND EXISTS (
SELECT 1
FROM json_each(m.tags_json)
WHERE json_each.value = ?
)`
)
.bind(normalizedOld)
.all<MemoTagUpdateRow>();
const now = isoNow();
const statements: D1PreparedStatement[] = [];
let updated = 0;
for (const row of rows.results) {
const currentTags = parseJsonArray(row.tags_json);
if (!currentTags.includes(normalizedOld)) {
continue;
}
const nextTags = normalizeTags(
currentTags.flatMap((tag) => {
if (tag !== normalizedOld) {
return [tag];
}
return normalizedNext ? [normalizedNext] : [];
})
);
statements.push(
db
.prepare(
`UPDATE memos
SET tags_json = ?, updated_by = ?, updated_at = ?
WHERE id = ? AND is_deleted = 0`
)
.bind(JSON.stringify(nextTags), actorLabel, now, row.id),
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(row.id),
db
.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
)
.bind(row.id, row.title, row.content_text, nextTags.join(" ")),
auditStatement(db, actor.actorType, actor.actorId, normalizedNext ? "tag.rename" : "tag.delete", "memo", row.id, {
from: normalizedOld,
to: normalizedNext,
})
);
updated += 1;
}
if (statements.length > 0) {
await db.batch(statements);
}
return updated;
};
const searchMemoSummaries = async (
db: D1Database,
options: { query?: string | null; notebookId?: string | null; limit: number }
): Promise<MemoSummary[]> => {
const q = options.query?.trim();
const notebookId = options.notebookId?.trim() || null;
const limit = clampNumber(options.limit, 1, 100);
if (q) {
const ftsQuery = toFtsQuery(q);
const likeQuery = `%${escapeLike(q)}%`;
if (ftsQuery) {
const rows = await db
.prepare(
`WITH raw_matches(memo_id, rank) AS (
SELECT memo_id, bm25(memos_fts)
FROM memos_fts
WHERE memos_fts MATCH ?
UNION ALL
SELECT m.id, 100.0
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.title LIKE ? ESCAPE '\\'
OR c.content_text LIKE ? ESCAPE '\\'
OR m.tags_json LIKE ? ESCAPE '\\'
),
search_matches AS (
SELECT memo_id, MIN(rank) AS rank
FROM raw_matches
GROUP BY memo_id
)
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
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
WHERE m.is_deleted = 0
AND (? IS NULL OR m.notebook_id = ?)
ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC
LIMIT ?`
)
.bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId, notebookId, limit)
.all<MemoSummaryRow>();
return rows.results.map(mapMemoSummary);
}
}
const rows = await db
.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
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.is_deleted = 0
AND (? IS NULL OR m.notebook_id = ?)
ORDER BY m.is_pinned DESC, m.updated_at DESC
LIMIT ?`
)
.bind(notebookId, notebookId, limit)
.all<MemoSummaryRow>();
return rows.results.map(mapMemoSummary);
};
const listMemosForMcp = async (
db: D1Database,
options: { notebookId?: string | null; limit: number; offset: number; includeContent: boolean }
) => {
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;
if (options.includeContent) {
const rows = await db
.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_json, c.content_markdown, c.content_text, c.content_hash,
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.is_deleted = 0
AND (? IS NULL OR m.notebook_id = ?)
ORDER BY m.updated_at DESC, m.id ASC
LIMIT ? OFFSET ?`
)
.bind(notebookId, notebookId, pageSize, offset)
.all<MemoDetailRow>();
const page = rows.results.slice(0, limit).map(mapMemoDetail);
return {
memos: page,
limit,
offset,
nextOffset: rows.results.length > limit ? offset + limit : null,
hasMore: rows.results.length > limit,
};
}
const rows = await db
.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
FROM memos m
INNER JOIN memo_contents c ON c.memo_id = m.id
WHERE m.is_deleted = 0
AND (? IS NULL OR m.notebook_id = ?)
ORDER BY m.updated_at DESC, m.id ASC
LIMIT ? OFFSET ?`
)
.bind(notebookId, notebookId, pageSize, offset)
.all<MemoSummaryRow>();
const page = rows.results.slice(0, limit).map(mapMemoSummary);
return {
memos: page,
limit,
offset,
nextOffset: rows.results.length > limit ? offset + limit : null,
hasMore: rows.results.length > limit,
};
};
const getNotebook = async (db: D1Database, id: string): Promise<Notebook | null> => {
const row = await db
.prepare(
notebookSelectSql(
`WHERE n.id = ? AND n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at`
)
)
.bind(id)
.first<NotebookRow>();
return row ? mapNotebook(row) : null;
};
const createNotebookRecord = async (
db: D1Database,
input: NotebookCreateInput & { sortOrder?: number },
actor: { actorType: "user" | "agent"; actorId: string | null }
) => {
const parentId = input.parentId ?? null;
if (parentId && !(await getNotebook(db, parentId))) {
throw new AppError("not_found", "Parent notebook not found", 404);
}
const id = createId("nb");
const now = isoNow();
const sortOrder = input.sortOrder ?? Date.now();
await db.batch([
db
.prepare(
`INSERT INTO notebooks (id, parent_id, name, slug, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(id, parentId, input.name, slugify(input.name), sortOrder, now, now),
auditStatement(db, actor.actorType, actor.actorId, "notebook.create", "notebook", id, {
name: input.name,
parentId,
sortOrder,
}),
]);
const notebook = await getNotebook(db, id);
if (!notebook) {
throw new AppError("not_found", "Notebook not found after create", 404);
}
return notebook;
};
const updateNotebookRecord = async (
db: D1Database,
id: string,
input: { name?: string; parentId?: string | null; sortOrder?: number },
actor: { actorType: "user" | "agent"; actorId: string | null }
) => {
const current = await getNotebook(db, id);
if (!current) {
throw new AppError("not_found", "Notebook not found", 404);
}
const nextName = input.name ?? current.name;
const nextParentId = input.parentId === undefined ? current.parentId : input.parentId;
const nextSortOrder = input.sortOrder ?? current.sortOrder;
const now = isoNow();
if (nextParentId === id) {
throw new AppError("bad_request", "Notebook cannot be its own parent", 400);
}
if (nextParentId) {
const parent = await getNotebook(db, nextParentId);
if (!parent) {
throw new AppError("not_found", "Parent notebook not found", 404);
}
if (await isNotebookDescendant(db, nextParentId, id)) {
throw new AppError("notebook_cycle", "Notebook cannot be moved into its own descendant.", 409);
}
}
await db.batch([
db
.prepare(
`UPDATE notebooks
SET name = ?, slug = ?, parent_id = ?, sort_order = ?, updated_at = ?
WHERE id = ? AND is_deleted = 0`
)
.bind(nextName, slugify(nextName), nextParentId ?? null, nextSortOrder, now, id),
auditStatement(db, actor.actorType, actor.actorId, "notebook.update", "notebook", id, input),
]);
const notebook = await getNotebook(db, id);
if (!notebook) {
throw new AppError("not_found", "Notebook not found after update", 404);
}
return notebook;
};
const isNotebookDescendant = async (db: D1Database, candidateId: string, ancestorId: string) => {
const row = await db
.prepare(
`WITH RECURSIVE descendants(id) AS (
SELECT id
FROM notebooks
WHERE parent_id = ? AND is_deleted = 0
UNION ALL
SELECT n.id
FROM notebooks n
INNER JOIN descendants d ON n.parent_id = d.id
WHERE n.is_deleted = 0
)
SELECT id
FROM descendants
WHERE id = ?
LIMIT 1`
)
.bind(ancestorId, candidateId)
.first<{ id: string }>();
return Boolean(row);
};
const getMemoDetailRow = async (
db: D1Database,
id: string,
includeDeleted = false
): Promise<MemoDetailRow | null> =>
db
.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_json, c.content_markdown, c.content_text, c.content_hash,
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.id = ? AND (? = 1 OR m.is_deleted = 0)`
)
.bind(id, includeDeleted ? 1 : 0)
.first<MemoDetailRow>();
const getMemoDetail = async (db: D1Database, id: string, includeDeleted = false): Promise<MemoDetail | null> => {
const row = await getMemoDetailRow(db, id, includeDeleted);
return row ? mapMemoDetail(row) : null;
};
const deleteMemosRecord = async (
db: D1Database,
resourcesBucket: R2Bucket,
memoIds: string[],
permanent: boolean,
actor: { actorType: "user" | "agent"; actorId: string | null }
) => {
const uniqueMemoIds = Array.from(new Set(memoIds));
if (uniqueMemoIds.length === 0) {
return 0;
}
const placeholders = uniqueMemoIds.map(() => "?").join(", ");
const expectedDeletedState = permanent ? 1 : 0;
const rows = await db
.prepare(
`SELECT id
FROM memos
WHERE is_deleted = ? AND id IN (${placeholders})`
)
.bind(expectedDeletedState, ...uniqueMemoIds)
.all<{ id: string }>();
if (rows.results.length !== uniqueMemoIds.length) {
throw new AppError(
"missing_memos",
permanent ? "One or more memos cannot be permanently deleted." : "One or more memos cannot be deleted.",
400
);
}
const now = isoNow();
const statements: D1PreparedStatement[] = [];
if (permanent) {
const resourceRows = await db
.prepare(
`SELECT object_key
FROM resources
WHERE memo_id IN (${placeholders})`
)
.bind(...uniqueMemoIds)
.all<{ object_key: string }>();
const objectKeys = resourceRows.results.map((resource) => resource.object_key);
if (objectKeys.length > 0) {
await resourcesBucket.delete(objectKeys);
}
statements.push(
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
db.prepare(`DELETE FROM resources WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
db.prepare(`DELETE FROM memo_revisions WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
db.prepare(`DELETE FROM memo_contents WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
db.prepare(`DELETE FROM memos WHERE is_deleted = 1 AND id IN (${placeholders})`).bind(...uniqueMemoIds)
);
for (const memoId of uniqueMemoIds) {
statements.push(auditStatement(db, actor.actorType, actor.actorId, "memo.delete_permanent", "memo", memoId, {}));
}
} else {
statements.push(
db
.prepare(
`UPDATE memos
SET is_deleted = 1, deleted_at = ?, updated_at = ?
WHERE is_deleted = 0 AND id IN (${placeholders})`
)
.bind(now, now, ...uniqueMemoIds),
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds)
);
for (const memoId of uniqueMemoIds) {
statements.push(auditStatement(db, actor.actorType, actor.actorId, "memo.delete", "memo", memoId, {}));
}
}
await db.batch(statements);
return uniqueMemoIds.length;
};
const emptyTrashMemosRecord = async (
db: D1Database,
resourcesBucket: R2Bucket,
actor: { actorType: "user" | "agent"; actorId: string | null }
) => {
const countRow = await db
.prepare(
`SELECT COUNT(*) AS count
FROM memos
WHERE is_deleted = 1`
)
.first<{ count: number }>();
const deleted = countRow?.count ?? 0;
if (deleted === 0) {
return 0;
}
const resourceRows = await db
.prepare(
`SELECT r.object_key
FROM resources r
INNER JOIN memos m ON m.id = r.memo_id
WHERE m.is_deleted = 1`
)
.all<{ object_key: string }>();
const objectKeys = resourceRows.results.map((resource) => resource.object_key);
if (objectKeys.length > 0) {
await resourcesBucket.delete(objectKeys);
}
await db.batch([
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
db.prepare(`UPDATE resources SET original_memo_id = NULL WHERE original_memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
db.prepare(`DELETE FROM resources WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
db.prepare(`DELETE FROM memo_revisions WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
db.prepare(`DELETE FROM memo_contents WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)`),
db.prepare(`DELETE FROM memos WHERE is_deleted = 1`),
auditStatement(db, actor.actorType, actor.actorId, "memo.trash_empty", "trash", "memos", { deleted }),
]);
return deleted;
};
const moveMemosToNotebook = async (
db: D1Database,
memoIds: string[],
notebookId: string,
actor: { actorType: "user" | "agent"; actorId: string | null },
actorLabel: string
) => {
const uniqueMemoIds = Array.from(new Set(memoIds));
if (uniqueMemoIds.length === 0) {
return 0;
}
const placeholders = uniqueMemoIds.map(() => "?").join(", ");
const rows = await db
.prepare(
`SELECT id, notebook_id
FROM memos
WHERE is_deleted = 0 AND id IN (${placeholders})`
)
.bind(...uniqueMemoIds)
.all<{ id: string; notebook_id: string }>();
if (rows.results.length !== uniqueMemoIds.length) {
throw new AppError("missing_memos", "One or more memos cannot be moved.", 400);
}
const now = isoNow();
const statements: D1PreparedStatement[] = [
db
.prepare(
`UPDATE memos
SET notebook_id = ?, updated_by = ?, updated_at = ?
WHERE is_deleted = 0 AND id IN (${placeholders})`
)
.bind(notebookId, actorLabel, now, ...uniqueMemoIds),
];
for (const row of rows.results) {
statements.push(
auditStatement(db, actor.actorType, actor.actorId, "memo.move", "memo", row.id, {
fromNotebookId: row.notebook_id,
toNotebookId: notebookId,
})
);
}
await db.batch(statements);
return uniqueMemoIds.length;
};
const mergeMemosRecord = async (
db: D1Database,
input: { memoIds: string[]; notebookId?: string; title?: string },
actor: { actorType: "user" | "agent"; actorId: string | null },
actorLabel: string
) => {
const uniqueMemoIds = Array.from(new Set(input.memoIds));
if (uniqueMemoIds.length < 2) {
throw new AppError("bad_request", "At least two memos are required to merge.", 400);
}
const placeholders = uniqueMemoIds.map(() => "?").join(", ");
const rows = await db
.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_json, c.content_markdown, c.content_text, c.content_hash,
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.is_deleted = 0 AND m.id IN (${placeholders})`
)
.bind(...uniqueMemoIds)
.all<MemoDetailRow>();
if (rows.results.length !== uniqueMemoIds.length) {
throw new AppError("missing_memos", "One or more memos cannot be merged.", 400);
}
if (input.notebookId && !(await getNotebook(db, input.notebookId))) {
throw new AppError("not_found", "Target notebook not found", 404);
}
const ordered = uniqueMemoIds
.map((memoId) => rows.results.find((row) => row.id === memoId))
.filter((row): row is MemoDetailRow => Boolean(row));
const notebookId = input.notebookId ?? ordered[0].notebook_id;
const title = input.title || `合并笔记 ${new Date().toLocaleDateString("zh-CN")}`;
const mergedMarkdown = ordered.map((memo) => memo.content_markdown).join("\n\n---\n\n");
const contentJson = markdownToDoc(mergedMarkdown);
const contentText = docToText(contentJson);
const tags = Array.from(new Set(ordered.flatMap((memo) => parseJsonArray(memo.tags_json))));
const excerpt = createExcerpt(contentText || title);
const contentHash = await sha256(mergedMarkdown + JSON.stringify(contentJson));
const newMemoId = createId("memo");
const now = isoNow();
await db.batch([
db
.prepare(
`INSERT INTO memos (
id, notebook_id, title, excerpt, tags_json, source_memo_ids, merge_source_count,
created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
newMemoId,
notebookId,
title,
excerpt,
JSON.stringify(tags),
JSON.stringify(uniqueMemoIds),
uniqueMemoIds.length,
actorLabel,
actorLabel,
now,
now
),
db
.prepare(
`INSERT INTO memo_contents (
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`
)
.bind(newMemoId, JSON.stringify(contentJson), mergedMarkdown, contentText, contentHash, now, now),
db
.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
)
.bind(newMemoId, title, contentText, tags.join(" ")),
db
.prepare(
`UPDATE memos
SET is_deleted = 1, deleted_at = ?, merged_into_memo_id = ?, merged_at = ?, updated_at = ?
WHERE id IN (${placeholders})`
)
.bind(now, newMemoId, now, now, ...uniqueMemoIds),
db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds),
db
.prepare(
`UPDATE resources
SET original_memo_id = COALESCE(original_memo_id, memo_id),
memo_id = ?,
updated_at = ?
WHERE memo_id IN (${placeholders})`
)
.bind(newMemoId, now, ...uniqueMemoIds),
auditStatement(db, actor.actorType, actor.actorId, "memo.merge", "memo", newMemoId, {
sourceMemoIds: uniqueMemoIds,
}),
]);
const memo = await getMemoDetail(db, newMemoId);
if (!memo) {
throw new AppError("not_found", "Merged memo not found after create.", 404);
}
return memo;
};
const createMemoRecord = async (
db: D1Database,
input: { notebookId: string; title?: string; contentMarkdown?: string; tags?: string[]; createdAt?: string; updatedAt?: string },
actor: { actorType: "user" | "agent"; actorId: string | null },
actorLabel: string
): Promise<MemoDetail> => {
const tags = normalizeTags(input.tags);
const contentMarkdown = input.contentMarkdown ?? "";
const contentJson = markdownToDoc(contentMarkdown);
const contentText = docToText(contentJson);
const title = normalizeMemoTitle(input.title);
const excerpt = createExcerpt(contentText);
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const id = createId("memo");
const now = isoNow();
const createdAt = input.createdAt ?? now;
const updatedAt = input.updatedAt ?? now;
await db.batch([
db
.prepare(
`INSERT INTO memos (
id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(id, input.notebookId, title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, createdAt, updatedAt),
db
.prepare(
`INSERT INTO memo_contents (
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`
)
.bind(id, JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, createdAt, updatedAt),
db
.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
)
.bind(id, title, contentText, tags.join(" ")),
auditStatement(db, actor.actorType, actor.actorId, "memo.create", "memo", id, {
notebookId: input.notebookId,
}),
]);
const memo = await getMemoDetail(db, id);
if (!memo) {
throw new Error("Memo was created but could not be read.");
}
return memo;
};
const updateMemoRecord = async (
db: D1Database,
id: string,
input: {
expectedRevision?: number;
notebookId?: string;
title?: string;
isPinned?: boolean;
contentMarkdown?: string;
tags?: string[];
createdAt?: string;
updatedAt?: string;
},
actor: { actorType: "user" | "agent"; actorId: string | null },
actorLabel: string
): Promise<{ memo: MemoDetail; error?: never; message?: never } | { error: string; message: string }> => {
const current = await getMemoDetailRow(db, id);
if (!current) {
return { error: "not_found", message: "Memo not found" };
}
if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) {
return { error: "revision_conflict", message: "Memo was updated elsewhere. Reload before saving." };
}
const isPinned = input.isPinned ?? Boolean(current.is_pinned);
const hasContentUpdate =
input.notebookId !== undefined ||
input.title !== undefined ||
input.contentMarkdown !== undefined ||
input.tags !== undefined ||
input.createdAt !== undefined ||
input.updatedAt !== undefined;
const now = isoNow();
const updatedAt = input.updatedAt ?? now;
if (!hasContentUpdate) {
if (input.isPinned === undefined || isPinned === Boolean(current.is_pinned)) {
const memo = await getMemoDetail(db, id);
if (!memo) {
return { error: "not_found", message: "Memo not found after update" };
}
return { memo };
}
await db.batch([
db
.prepare(
`UPDATE memos
SET is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
WHERE id = ? AND is_deleted = 0`
)
.bind(isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
auditStatement(db, actor.actorType, actor.actorId, isPinned ? "memo.pin" : "memo.unpin", "memo", id, {}),
]);
const memo = await getMemoDetail(db, id);
if (!memo) {
return { error: "not_found", message: "Memo not found after update" };
}
return { memo };
}
const currentContentJson = parseDoc(current.content_json);
const contentJson = input.contentMarkdown !== undefined ? markdownToDoc(input.contentMarkdown) : currentContentJson;
const contentMarkdown =
input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson);
const contentText = docToText(contentJson);
const title =
input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title);
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
const excerpt = createExcerpt(contentText);
const notebookId = input.notebookId ?? current.notebook_id;
const nextRevision = current.revision + 1;
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const revisionStatements = (await shouldSnapshotMemoRevision(db, current, title, JSON.stringify(tags), contentHash, updatedAt))
? [createMemoRevisionStatement(db, current, actorLabel, updatedAt)]
: [];
await db.batch([
...revisionStatements,
db
.prepare(
`UPDATE memos
SET notebook_id = ?, title = ?, excerpt = ?, tags_json = ?, is_pinned = ?, updated_by = ?, updated_at = ?, created_at = COALESCE(?, created_at)
WHERE id = ? AND is_deleted = 0`
)
.bind(notebookId, title, excerpt, JSON.stringify(tags), isPinned ? 1 : 0, actorLabel, updatedAt, input.createdAt ?? null, id),
db
.prepare(
`UPDATE memo_contents
SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?,
revision = ?, updated_at = ?, created_at = COALESCE(?, created_at)
WHERE memo_id = ?`
)
.bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, updatedAt, input.createdAt ?? null, id),
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id),
db
.prepare(
`INSERT INTO memos_fts (memo_id, title, content_text, tags)
VALUES (?, ?, ?, ?)`
)
.bind(id, title, contentText, tags.join(" ")),
auditStatement(db, actor.actorType, actor.actorId, "memo.update", "memo", id, {
revision: nextRevision,
}),
]);
const memo = await getMemoDetail(db, id);
if (!memo) {
return { error: "not_found", message: "Memo not found after update" };
}
return { memo };
};
const getMemoRevisionRow = async (
db: D1Database,
memoId: string,
revisionId: string
): Promise<MemoRevisionRow | null> =>
db
.prepare(
`SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown,
content_text, content_hash, created_by, created_at
FROM memo_revisions
WHERE id = ? AND memo_id = ?`
)
.bind(revisionId, memoId)
.first<MemoRevisionRow>();
const getLatestMemoRevisionRow = async (db: D1Database, memoId: string): Promise<MemoRevisionRow | null> =>
db
.prepare(
`SELECT id, memo_id, revision, title, tags_json, content_json, content_markdown,
content_text, content_hash, created_by, created_at
FROM memo_revisions
WHERE memo_id = ?
ORDER BY created_at DESC, revision DESC
LIMIT 1`
)
.bind(memoId)
.first<MemoRevisionRow>();
const createMemoRevisionStatement = (
db: D1Database,
current: MemoDetailRow,
actorLabel: string,
createdAt: string
) =>
db
.prepare(
`INSERT INTO memo_revisions (
id, memo_id, revision, title, content_json, content_markdown,
content_hash, created_by, created_at, tags_json, content_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
createId("rev"),
current.id,
current.revision,
current.title,
current.content_json,
current.content_markdown,
current.content_hash,
actorLabel,
createdAt,
current.tags_json,
current.content_text
);
const shouldSnapshotMemoRevision = async (
db: D1Database,
current: MemoDetailRow,
nextTitle: string | null,
nextTagsJson: string,
nextContentHash: string,
now: string
) => {
const changed =
(current.title ?? "") !== (nextTitle ?? "") ||
current.tags_json !== nextTagsJson ||
current.content_hash !== nextContentHash;
if (!changed) {
return false;
}
const latest = await getLatestMemoRevisionRow(db, current.id);
if (!latest) {
return true;
}
const alreadyCapturedCurrent =
(latest.title ?? "") === (current.title ?? "") &&
latest.tags_json === current.tags_json &&
latest.content_hash === current.content_hash;
if (alreadyCapturedCurrent) {
return false;
}
return Date.parse(now) - Date.parse(latest.created_at) >= REVISION_SNAPSHOT_INTERVAL_MS;
};
const getResourceRow = async (db: D1Database, id: string): Promise<ResourceRow | null> =>
db
.prepare(
`SELECT id, memo_id, original_memo_id, bucket_name, object_key, kind, mime_type,
filename, byte_size, sha256, width, height, created_at, updated_at
FROM resources
WHERE id = ? AND is_deleted = 0`
)
.bind(id)
.first<ResourceRow>();
const getResourceRowsForMemo = async (db: D1Database, memoId: string): Promise<ResourceRow[]> => {
const rows = await db
.prepare(
`SELECT id, memo_id, original_memo_id, bucket_name, object_key, kind, mime_type,
filename, byte_size, sha256, width, height, created_at, updated_at
FROM resources
WHERE memo_id = ?`
)
.bind(memoId)
.all<ResourceRow>();
return rows.results;
};
const parseJsonArray = (json: string): string[] => {
try {
const value = JSON.parse(json);
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
} catch {
return [];
}
};
const parseDoc = (json: string): TiptapDoc => {
try {
const value = JSON.parse(json);
return value && typeof value === "object" ? (value as TiptapDoc) : emptyDoc();
} catch {
return emptyDoc();
}
};
const audit = async (
db: D1Database,
actorType: "user" | "agent" | "system",
actorId: string | null,
action: string,
entityType: string,
entityId: string,
metadata: unknown
) => auditStatement(db, actorType, actorId, action, entityType, entityId, metadata).run();
const auditStatement = (
db: D1Database,
actorType: "user" | "agent" | "system",
actorId: string | null,
action: string,
entityType: string,
entityId: string,
metadata: unknown
) =>
db
.prepare(
`INSERT INTO audit_events (
id, actor_type, actor_id, action, entity_type, entity_id, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(createId("audit"), actorType, actorId, action, entityType, entityId, JSON.stringify(metadata ?? {}), isoNow());
const createId = (prefix: string) => `${prefix}_${crypto.randomUUID().replace(/-/g, "")}`;
const isoNow = () => new Date().toISOString();
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/['"]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const normalizeMemoTitle = (value: string | null | undefined) => {
const title = value?.trim();
return title || DEFAULT_MEMO_TITLE;
};
const clampNumber = (value: number, min: number, max: number) => {
if (Number.isNaN(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
};
const toFtsQuery = (value: string) => {
const tokens = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
return tokens
.slice(0, 8)
.map((token) => `"${token.replace(/"/g, '""')}"`)
.join(" ");
};
const escapeLike = (value: string) => value.replace(/[\\%_]/g, (character) => `\\${character}`);
const sha256 = async (value: string) => {
const bytes = new TextEncoder().encode(value);
return sha256Bytes(bytes);
};
const sha256Bytes = async (bytes: Uint8Array) => {
const digest = await crypto.subtle.digest("SHA-256", bytes.slice());
const hashArray = new Uint8Array(digest);
let hexString = "";
for (let i = 0; i < hashArray.length; i++) {
const hex = hashArray[i].toString(16);
hexString += hex.length === 1 ? "0" + hex : hex;
}
return hexString;
};
const inferImageExtension = (filename: string, mimeType: string) => {
const extension = /\.(png|jpe?g|gif|webp|avif)$/i.exec(filename)?.[0]?.toLowerCase();
if (extension) {
return extension === ".jpeg" ? ".jpg" : extension;
}
switch (mimeType) {
case "image/png":
return ".png";
case "image/jpeg":
return ".jpg";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
case "image/avif":
return ".avif";
default:
return "";
}
};
const normalizeFilename = (filename: string) =>
filename
.trim()
.replace(/[\\/]/g, "-")
.replace(/[\u0000-\u001f\u007f]/g, "")
.slice(0, 160);
const contentDispositionInline = (filename: string | null) => {
if (!filename) {
return "inline";
}
const fallback = normalizeFilename(filename).replace(/"/g, "'");
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
};
const decodeTagParam = (value: string) => {
try {
return decodeURIComponent(value).trim();
} catch {
return value.trim();
}
};
const notFound = (c: Context, message: string) =>
c.json(
{
error: {
code: "not_found",
message,
},
},
404
);
const badRequest = (c: Context, message: string) =>
c.json(
{
error: {
code: "bad_request",
message,
},
},
400
);
const apiError = (c: Context, code: string, message: string, status: number) =>
c.json(
{
error: {
code,
message,
},
},
status as 400
);
const conflict = (c: Context, code: string, message: string) =>
c.json(
{
error: {
code,
message,
},
},
409
);
const unauthorized = (c: Context, message: string) =>
c.json(
{
error: {
code: "unauthorized",
message,
},
},
401
);
const forbidden = (c: Context, message: string) =>
c.json(
{
error: {
code: "forbidden",
message,
},
},
403
);