>;
+}
diff --git a/plugins/inspiration/tsconfig.json b/plugins/inspiration/tsconfig.json
new file mode 100644
index 0000000..246146a
--- /dev/null
+++ b/plugins/inspiration/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/plugins/inspiration/tsup.config.ts b/plugins/inspiration/tsup.config.ts
new file mode 100644
index 0000000..7c1e5c2
--- /dev/null
+++ b/plugins/inspiration/tsup.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.ts", "src/cli.ts"],
+ outDir: "dist",
+ format: "esm",
+ dts: true,
+ sourcemap: true,
+ clean: true,
+});
diff --git a/plugins/inspiration/web/index.js b/plugins/inspiration/web/index.js
new file mode 100644
index 0000000..c9fcc9f
--- /dev/null
+++ b/plugins/inspiration/web/index.js
@@ -0,0 +1,348 @@
+const API_PREFIX = "/plugins/inspiration";
+
+function csv(value) {
+ return String(value ?? "")
+ .split(/[,,]/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function formatMinute(minute) {
+ const value = Number(minute) || 0;
+ return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
+}
+
+function minuteOfDay(value) {
+ const [hour, minute] = String(value ?? "").split(":").map(Number);
+ return hour * 60 + minute;
+}
+
+function listPath(filters) {
+ const params = new URLSearchParams({ limit: "50" });
+ if (filters.text) params.set("text", filters.text);
+ for (const tag of filters.tags) params.append("tag", tag);
+ if (filters.project) params.set("project", filters.project);
+ for (const status of filters.statuses) params.append("status", status);
+ if (filters.includeArchived) params.set("includeArchived", "true");
+ return `${API_PREFIX}/inspirations?${params}`;
+}
+
+export async function activate({ api }) {
+ let filters = {
+ text: "",
+ tags: [],
+ project: "",
+ statuses: ["inbox", "kept"],
+ includeArchived: false,
+ };
+ let latestInspirations = [];
+ let latestSettings = null;
+ let latestDeliveries = [];
+ let currentCandidate = null;
+
+ async function loadSnapshot() {
+ const [list, settings, ledger] = await Promise.all([
+ api(listPath(filters)),
+ api(`${API_PREFIX}/flow/settings`),
+ api(`${API_PREFIX}/flow/deliveries?limit=20`),
+ ]);
+ latestInspirations = Array.isArray(list?.items) ? list.items : [];
+ latestSettings = settings;
+ latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : [];
+ return {
+ inspirationList: list,
+ inspirationFlowSettings: settings,
+ inspirationFlowDeliveries: ledger,
+ };
+ }
+
+ const setError = ($, id, error) => {
+ const element = $(id);
+ if (element) element.textContent = error instanceof Error ? error.message : String(error ?? "");
+ };
+
+ return {
+ id: "inspiration",
+ faces() {
+ return [{ type: "inspiration-inbox" }, { type: "inspiration-flow" }];
+ },
+ load: loadSnapshot,
+ async loadLive() {
+ const [list, ledger] = await Promise.all([
+ api(listPath(filters)),
+ api(`${API_PREFIX}/flow/deliveries?limit=20`),
+ ]);
+ latestInspirations = Array.isArray(list?.items) ? list.items : [];
+ latestDeliveries = Array.isArray(ledger?.deliveries) ? ledger.deliveries : [];
+ return {
+ inspirationList: list,
+ inspirationFlowDeliveries: ledger,
+ };
+ },
+ renderFace(face, { esc, escA }) {
+ if (face.type === "inspiration-inbox") {
+ const rows = latestInspirations.map((item) => {
+ const tags = Array.isArray(item.tags)
+ ? item.tags.map((tag) => `#${esc(tag)}`).join(" ")
+ : "";
+ const metadata = [item.project, item.status, `v${item.version}`]
+ .filter(Boolean)
+ .map((value) => esc(value))
+ .join(" · ");
+ if (item.status === "archived") {
+ return `
+ ${metadata}
+ ${esc(item.content)}
+ ${tags}
+
+ `;
+ }
+ return `
+ ${metadata}
+
+
+
+
+
+
+ ${tags}
+
+
+ `;
+ }).join("");
+ return ``;
+ }
+
+ if (face.type === "inspiration-flow") {
+ const candidate = currentCandidate;
+ const candidateBody = candidate
+ ? `
+ 本次浮现 · ${esc(candidate.inspiration.project ?? "未分项目")} · v${esc(candidate.inspiration.version)}
+ ${esc(candidate.inspiration.content)}
+ ${(candidate.inspiration.tags ?? []).map((tag) => `#${esc(tag)}`).join(" ")}
+ ${(candidate.explanation ?? []).map((reason) => esc(reason)).join(" · ")}
+
+
+
+
+
+ `
+ : '点「浮现下一条」使用服务端选择器。
';
+ const deliveryRows = latestDeliveries.map((delivery) =>
+ `
+ ${esc(delivery.status)} · ${esc(delivery.outcome ?? "未处理")}
+
+ ${esc(new Date(delivery.surfacedAt).toLocaleString("zh-CN"))}
+
`
+ ).join("");
+ const settings = latestSettings;
+ const settingsBody = settings
+ ? `
+
+
+
+
+
+
+
+
+
+
+
+
`
+ : 'Flow 设置不可用。
';
+ return `
+
灵感 Flow
+
+ ${candidateBody}
+
Flow 设置${settings ? ` · v${esc(settings.version)}` : ""}
+
+
投递历史
+
+
`;
+ }
+ return null;
+ },
+ async handleAction(action, { id, $ }) {
+ if (action === "capture-inspiration") {
+ try {
+ await api(`${API_PREFIX}/inspirations`, {
+ method: "POST",
+ body: JSON.stringify({
+ content: $("inspirationNewContent")?.value ?? "",
+ tags: csv($("inspirationNewTags")?.value),
+ project: $("inspirationNewProject")?.value?.trim() || null,
+ status: "inbox",
+ }),
+ });
+ return { handled: true, message: "灵感已捕捉" };
+ } catch (error) {
+ setError($, "inspirationNewError", error);
+ return { handled: true, refresh: false };
+ }
+ }
+ if (action === "filter-inspirations") {
+ filters = {
+ text: $("inspirationFilterText")?.value?.trim() || "",
+ tags: csv($("inspirationFilterTags")?.value),
+ project: $("inspirationFilterProject")?.value?.trim() || "",
+ statuses: [
+ ...($("inspirationFilterInbox")?.checked ? ["inbox"] : []),
+ ...($("inspirationFilterKept")?.checked ? ["kept"] : []),
+ ...($("inspirationFilterArchived")?.checked ? ["archived"] : []),
+ ],
+ includeArchived: Boolean($("inspirationIncludeArchived")?.checked),
+ };
+ return { handled: true, message: "灵感筛选已应用" };
+ }
+ if (action === "clear-inspiration-filters") {
+ filters = { text: "", tags: [], project: "", statuses: ["inbox", "kept"], includeArchived: false };
+ return { handled: true, message: "灵感筛选已清除" };
+ }
+
+ const inspiration = latestInspirations.find((item) => item.id === id);
+ if (action === "edit-inspiration" && inspiration) {
+ try {
+ await api(`${API_PREFIX}/inspirations/${encodeURIComponent(id)}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ expectedVersion: inspiration.version,
+ content: $(`inspirationContent:${id}`)?.value ?? "",
+ tags: csv($(`inspirationTags:${id}`)?.value),
+ project: $(`inspirationProject:${id}`)?.value?.trim() || null,
+ status: $(`inspirationStatus:${id}`)?.value,
+ }),
+ });
+ return { handled: true, message: "灵感已整理" };
+ } catch (error) {
+ setError($, `inspirationError:${id}`, error);
+ return { handled: true, refresh: false };
+ }
+ }
+ if ((action === "archive-inspiration" || action === "restore-inspiration") && inspiration) {
+ const operation = action === "archive-inspiration" ? "archive" : "restore";
+ await api(`${API_PREFIX}/inspirations/${encodeURIComponent(id)}/${operation}`, {
+ method: "POST",
+ body: JSON.stringify({ expectedVersion: inspiration.version }),
+ });
+ return { handled: true, message: operation === "archive" ? "灵感已归档" : "灵感已恢复" };
+ }
+ if (action === "next-inspiration") {
+ const result = await api(`${API_PREFIX}/flow/next`, {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ currentCandidate = result?.candidate ?? null;
+ return {
+ handled: true,
+ message: currentCandidate ? "浮现了一条灵感" : "暂无符合条件的灵感",
+ };
+ }
+ if (action.startsWith("inspiration-outcome-") && currentCandidate?.delivery.id === id) {
+ const outcome = action.slice("inspiration-outcome-".length);
+ const body = {
+ expectedDeliveryVersion: currentCandidate.delivery.version,
+ expectedInspirationVersion: currentCandidate.inspiration.version,
+ outcome,
+ };
+ if (outcome === "later" && $("inspirationSnooze")?.value !== "") {
+ body.snoozeMinutes = Number($("inspirationSnooze").value);
+ }
+ try {
+ await api(`${API_PREFIX}/flow/deliveries/${encodeURIComponent(id)}/outcome`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ currentCandidate = null;
+ return { handled: true, message: "Flow 结果已记录" };
+ } catch (error) {
+ setError($, "inspirationFlowError", error);
+ return { handled: true, refresh: false };
+ }
+ }
+ if (action === "save-inspiration-settings" && latestSettings) {
+ try {
+ await api(`${API_PREFIX}/flow/settings`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ expectedVersion: latestSettings.version,
+ enabled: Boolean($("inspirationFlowEnabled")?.checked),
+ intervalMinutes: Number($("inspirationFlowInterval")?.value),
+ quietStartMinute: minuteOfDay($("inspirationFlowQuietStart")?.value),
+ quietEndMinute: minuteOfDay($("inspirationFlowQuietEnd")?.value),
+ cooldownMinutes: Number($("inspirationFlowCooldown")?.value),
+ dailyLimit: Number($("inspirationFlowDailyLimit")?.value),
+ defaultSnoozeMinutes: Number($("inspirationFlowDefaultSnooze")?.value),
+ statuses: [
+ ...($("inspirationFlowStatusInbox")?.checked ? ["inbox"] : []),
+ ...($("inspirationFlowStatusKept")?.checked ? ["kept"] : []),
+ ],
+ tags: csv($("inspirationFlowTags")?.value),
+ projects: csv($("inspirationFlowProjects")?.value),
+ }),
+ });
+ return { handled: true, message: "Flow 设置已保存" };
+ } catch (error) {
+ setError($, "inspirationSettingsError", error);
+ return { handled: true, refresh: false };
+ }
+ }
+ return { handled: false };
+ },
+ async unmount() {
+ latestInspirations = [];
+ latestSettings = null;
+ latestDeliveries = [];
+ currentCandidate = null;
+ },
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f31cd2e..6f497c0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@echolog/plugin-inspiration':
+ specifier: workspace:*
+ version: link:plugins/inspiration
'@echolog/plugin-screen-time':
specifier: workspace:*
version: link:plugins/screen-time
@@ -85,6 +88,28 @@ importers:
specifier: ^5.8.3
version: 5.9.3
+ plugins/inspiration:
+ dependencies:
+ '@echolog/plugin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/plugin-sdk
+ drizzle-orm:
+ specifier: ^0.44.0
+ version: 0.44.7(postgres@3.4.9)
+ nanoid:
+ specifier: ^5.1.5
+ version: 5.1.11
+ postgres:
+ specifier: ^3.4.7
+ version: 3.4.9
+ devDependencies:
+ tsup:
+ specifier: ^8.5.0
+ version: 8.5.1(tsx@4.22.2)(typescript@5.9.3)(yaml@2.9.0)
+ typescript:
+ specifier: ^5.8.3
+ version: 5.9.3
+
plugins/screen-time:
dependencies:
'@echolog/plugin-sdk':
diff --git a/src/cli/index.ts b/src/cli/index.ts
index b9bf254..e0a1ff0 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -834,6 +834,485 @@ withJson(
})
);
+type InspirationLifecycleStatus = "inbox" | "kept" | "archived";
+type InspirationFlowOutcome =
+ | "viewed"
+ | "continued"
+ | "kept"
+ | "later"
+ | "archived";
+
+const inspirationApiPrefix = "/api/plugins/inspiration";
+
+function inspirationInteger(value: string, option: string, minimum = 0): number {
+ const parsed = Number(value);
+ if (!Number.isInteger(parsed) || parsed < minimum) {
+ throw new CliUsageError(`${option} 必须是大于或等于 ${minimum} 的整数`);
+ }
+ return parsed;
+}
+
+function inspirationLimit(value: string, option: string): number {
+ const parsed = inspirationInteger(value, option, 1);
+ if (parsed > 100) throw new CliUsageError(`${option} 必须是 1 到 100 的整数`);
+ return parsed;
+}
+
+function inspirationBoolean(value: string, option: string): boolean {
+ if (value === "true") return true;
+ if (value === "false") return false;
+ throw new CliUsageError(`${option} 只能是 true 或 false`);
+}
+
+function inspirationStatus(value: string, allowArchived = true): InspirationLifecycleStatus {
+ if (value === "inbox" || value === "kept" || (allowArchived && value === "archived")) {
+ return value;
+ }
+ throw new CliUsageError(
+ allowArchived
+ ? "status 只能是 inbox、kept 或 archived"
+ : "status 只能是 inbox 或 kept"
+ );
+}
+
+function inspirationOutcome(value: string): InspirationFlowOutcome {
+ if (
+ value === "viewed" ||
+ value === "continued" ||
+ value === "kept" ||
+ value === "later" ||
+ value === "archived"
+ ) {
+ return value;
+ }
+ throw new CliUsageError(
+ "outcome 只能是 viewed、continued、kept、later 或 archived"
+ );
+}
+
+function inspirationMinute(value: string, option: string): number {
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
+ if (!match) throw new CliUsageError(`${option} 必须是 HH:mm`);
+ const hour = Number(match[1]);
+ const minute = Number(match[2]);
+ if (hour > 23 || minute > 59) {
+ throw new CliUsageError(`${option} 必须是有效的 24 小时时间`);
+ }
+ return hour * 60 + minute;
+}
+
+function inspirationItems(result: any): any[] {
+ if (Array.isArray(result)) return result;
+ if (Array.isArray(result?.items)) return result.items;
+ return [];
+}
+
+function printInspirations(result: any): void {
+ const items = inspirationItems(result);
+ if (items.length === 0) {
+ console.log("暂无灵感");
+ return;
+ }
+ for (const item of items) {
+ const tags = item.tags?.length ? ` #${item.tags.join(" #")}` : "";
+ const project = item.project ? ` · ${item.project}` : "";
+ console.log(`${item.id}\tv${item.version}\t${item.status}${project}${tags}`);
+ console.log(` ${item.content}`);
+ }
+}
+
+const inspiration = program
+ .command("inspiration")
+ .description("独立捕捉、整理灵感并使用确定性的 Inspiration Flow;不依赖活跃记录。")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration capture "为发布页画一张对照图" --tags design,launch
+ $ el inspiration list --statuses inbox,kept --json
+ $ el inspiration flow next --json
+ $ el inspiration flow outcome later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120
+`
+ );
+
+withJson(
+ inspiration
+ .command("capture ")
+ .description("捕捉一条独立灵感;status 只能是 inbox 或 kept,默认 inbox。")
+ .option("-t, --tags ", "标签,逗号分隔,如 design,launch")
+ .option("-p, --project ", "可选自由文本项目分组")
+ .option("--status ", "生命周期状态: inbox | kept", "inbox")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration capture "试试更短的 onboarding" --tags product,ux
+ $ el inspiration capture "保留这条原则" --status kept --project EchoLog --json
+`
+ )
+).action(
+ action(async (
+ thisCommand,
+ content: string,
+ opts: { tags?: string; project?: string; status: string }
+ ) => {
+ const created = await post(`${inspirationApiPrefix}/inspirations`, {
+ content,
+ tags: splitCsv(opts.tags),
+ project: opts.project?.trim() || null,
+ status: inspirationStatus(opts.status, false),
+ });
+ printSuccess(thisCommand, created, () => {
+ console.log(`✓ 已捕捉灵感 [${(created as any).id}] v${(created as any).version}`);
+ console.log(` ${(created as any).content}`);
+ });
+ })
+);
+
+withJson(
+ inspiration
+ .command("list")
+ .alias("inbox")
+ .description("列出或筛选灵感;支持文本、标签、项目、生命周期与归档历史。")
+ .option("--text ", "正文包含的文本")
+ .option("--tags ", "必须匹配的标签,逗号分隔")
+ .option("--project ", "精确项目分组")
+ .option("--statuses ", "状态,逗号分隔: inbox | kept | archived")
+ .option("--include-archived", "包含 archived 历史")
+ .option("--limit ", "返回数量,范围 1–100", "50")
+ .option("--created-before ", "只看此创建时间之前,ISO 8601 且包含时区")
+ .option("--created-after ", "只看此创建时间之后,ISO 8601 且包含时区")
+ .option("--cursor ", "上一页响应的 opaque nextCursor")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration list
+ $ el inspiration inbox --text onboarding --tags ux,product
+ $ el inspiration list --statuses kept,archived --include-archived --created-before 2026-08-24T12:00:00+08:00 --json
+`
+ )
+).action(
+ action(async (thisCommand, opts: {
+ text?: string;
+ tags?: string;
+ project?: string;
+ statuses?: string;
+ includeArchived?: boolean;
+ limit: string;
+ createdBefore?: string;
+ createdAfter?: string;
+ cursor?: string;
+ }) => {
+ const params = new URLSearchParams();
+ if (opts.text) params.set("text", opts.text);
+ for (const tag of splitCsv(opts.tags)) params.append("tag", tag);
+ if (opts.project) params.set("project", opts.project);
+ if (opts.statuses) {
+ const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value));
+ for (const status of statuses) params.append("status", status);
+ }
+ if (opts.includeArchived) params.set("includeArchived", "true");
+ params.set("limit", String(inspirationLimit(opts.limit, "--limit")));
+ if (opts.createdBefore) params.set("createdBefore", opts.createdBefore);
+ if (opts.createdAfter) params.set("createdAfter", opts.createdAfter);
+ if (opts.cursor) params.set("cursor", opts.cursor);
+ const result = await api(`${inspirationApiPrefix}/inspirations?${params}`);
+ printSuccess(thisCommand, result, () => printInspirations(result));
+ })
+);
+
+withJson(
+ inspiration
+ .command("show ")
+ .description("查看一条灵感;id 来自 inspiration list。")
+ .addHelpText("after", `\n示例:\n $ el inspiration show --json\n`)
+).action(
+ action(async (thisCommand, id: string) => {
+ const item = await api(`${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`);
+ printSuccess(thisCommand, item, () => printInspirations([item]));
+ })
+);
+
+withJson(
+ inspiration
+ .command("edit ")
+ .description("按 expectedVersion 编辑正文、标签、项目或 inbox/kept 状态;冲突返回 409。")
+ .requiredOption("--version ", "当前 inspiration version,必须与服务端一致")
+ .option("--content ", "替换正文")
+ .option("--tags ", "替换标签,逗号分隔;空字符串清空")
+ .option("--project ", "替换项目分组")
+ .option("--clear-project", "清除项目分组")
+ .option("--status ", "生命周期状态: inbox | kept")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration edit --version 2 --content "更明确的想法" --tags product,copy
+ $ el inspiration edit --version 3 --clear-project --status kept --json
+`
+ )
+).action(
+ action(async (thisCommand, id: string, opts: {
+ version: string;
+ content?: string;
+ tags?: string;
+ project?: string;
+ clearProject?: boolean;
+ status?: string;
+ }) => {
+ if (opts.project != null && opts.clearProject) {
+ throw new CliUsageError("--project 和 --clear-project 不能同时使用");
+ }
+ const body: Record = {
+ expectedVersion: inspirationInteger(opts.version, "--version", 1),
+ };
+ if (opts.content != null) body.content = opts.content;
+ if (opts.tags != null) body.tags = splitCsv(opts.tags);
+ if (opts.project != null) body.project = opts.project.trim() || null;
+ if (opts.clearProject) body.project = null;
+ if (opts.status != null) body.status = inspirationStatus(opts.status, false);
+ if (Object.keys(body).length === 1) {
+ throw new CliUsageError("至少指定 --content、--tags、--project、--clear-project 或 --status 之一");
+ }
+ const updated = await patch(
+ `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}`,
+ body
+ );
+ printSuccess(thisCommand, updated, () => {
+ console.log(`✓ 已更新灵感 [${(updated as any).id}] v${(updated as any).version}`);
+ });
+ })
+);
+
+for (const operation of ["archive", "restore"] as const) {
+ withJson(
+ inspiration
+ .command(`${operation} `)
+ .description(
+ operation === "archive"
+ ? "按 expectedVersion 归档灵感;历史仍可查询。"
+ : "按 expectedVersion 将已归档灵感恢复到 inbox。"
+ )
+ .requiredOption("--version ", "当前 inspiration version,必须与服务端一致")
+ .addHelpText(
+ "after",
+ `\n示例:\n $ el inspiration ${operation} --version 2 --json\n`
+ )
+ ).action(
+ action(async (thisCommand, id: string, opts: { version: string }) => {
+ const result = await post(
+ `${inspirationApiPrefix}/inspirations/${encodeURIComponent(id)}/${operation}`,
+ { expectedVersion: inspirationInteger(opts.version, "--version", 1) }
+ );
+ printSuccess(thisCommand, result, () => {
+ console.log(
+ `✓ 灵感已${operation === "archive" ? "归档" : "恢复"} [${(result as any).id}] v${(result as any).version}`
+ );
+ });
+ })
+ );
+}
+
+const inspirationFlow = inspiration
+ .command("flow")
+ .description("手动浮现灵感、记录用户结果,并查看 Flow 设置与投递历史。")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration flow next --idempotency-key manual-20260824 --json
+ $ el inspiration flow deliveries --limit 20
+`
+ );
+
+withJson(
+ inspirationFlow
+ .command("next")
+ .description("使用服务端确定性选择器浮现下一条;不在客户端推断候选。")
+ .option("--idempotency-key ", "可选手动幂等键,最长 200 字符")
+ .addHelpText(
+ "after",
+ `\n示例:\n $ el inspiration flow next\n $ el inspiration flow next --idempotency-key morning-review --json\n`
+ )
+).action(
+ action(async (thisCommand, opts: { idempotencyKey?: string }) => {
+ const result = await post(`${inspirationApiPrefix}/flow/next`,
+ opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}
+ );
+ printSuccess(thisCommand, result, () => {
+ const candidate = (result as any).candidate;
+ if (!candidate) {
+ console.log("暂无可浮现的灵感");
+ for (const reason of (result as any).explanation ?? []) console.log(` - ${reason}`);
+ return;
+ }
+ console.log(`${candidate.inspiration.content}`);
+ console.log(` inspiration ${candidate.inspiration.id} v${candidate.inspiration.version}`);
+ console.log(` delivery ${candidate.delivery.id} v${candidate.delivery.version}`);
+ for (const reason of candidate.explanation ?? []) console.log(` - ${reason}`);
+ });
+ })
+);
+
+withJson(
+ inspirationFlow
+ .command("outcome ")
+ .description("记录 Flow 结果: viewed | continued | kept | later | archived。")
+ .requiredOption("--delivery-version ", "当前 delivery version")
+ .requiredOption("--inspiration-version ", "候选 inspiration version")
+ .option("--snooze-minutes ", "later 的稍后分钟数;省略时使用服务端默认值")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration flow outcome viewed --delivery-version 1 --inspiration-version 3
+ $ el inspiration flow outcome later --delivery-version 1 --inspiration-version 3 --snooze-minutes 120 --json
+`
+ )
+).action(
+ action(async (thisCommand, deliveryId: string, outcomeValue: string, opts: {
+ deliveryVersion: string;
+ inspirationVersion: string;
+ snoozeMinutes?: string;
+ }) => {
+ const outcome = inspirationOutcome(outcomeValue);
+ const body: Record = {
+ expectedDeliveryVersion: inspirationInteger(opts.deliveryVersion, "--delivery-version", 1),
+ expectedInspirationVersion: inspirationInteger(opts.inspirationVersion, "--inspiration-version", 1),
+ outcome,
+ };
+ if (opts.snoozeMinutes != null) {
+ if (outcome !== "later") {
+ throw new CliUsageError("--snooze-minutes 只能与 outcome=later 一起使用");
+ }
+ body.snoozeMinutes = inspirationInteger(opts.snoozeMinutes, "--snooze-minutes", 1);
+ }
+ const result = await post(
+ `${inspirationApiPrefix}/flow/deliveries/${encodeURIComponent(deliveryId)}/outcome`,
+ body
+ );
+ printSuccess(thisCommand, result, () => {
+ console.log(`✓ 已记录 Flow 结果: ${outcome}`);
+ });
+ })
+);
+
+const inspirationFlowSettings = inspirationFlow
+ .command("settings")
+ .description("查看 Flow 设置;使用 settings set 提交完整的版本化设置。")
+ .addHelpText(
+ "after",
+ `\n示例:\n $ el inspiration flow settings --json\n $ el inspiration flow settings set --help\n`
+ );
+
+withJson(inspirationFlowSettings).action(
+ action(async (thisCommand) => {
+ const settings = await api(`${inspirationApiPrefix}/flow/settings`);
+ printSuccess(thisCommand, settings, () => {
+ const value = settings as any;
+ console.log(`Flow: ${value.enabled ? "已启用" : "未启用"} · v${value.version}`);
+ console.log(` 周期 ${value.intervalMinutes} 分钟 · 冷却 ${value.cooldownMinutes} 分钟 · 每日上限 ${value.dailyLimit}`);
+ console.log(` 安静时间 ${formatMinute(value.quietStartMinute)}–${formatMinute(value.quietEndMinute)}`);
+ });
+ })
+);
+
+withJson(
+ inspirationFlowSettings
+ .command("set")
+ .description("提交完整 FlowSettingsUpdate;所有选项必填,版本冲突返回 409。")
+ .requiredOption("--version ", "当前 settings version")
+ .requiredOption("--enabled ", "是否启用定时 Flow: true | false")
+ .requiredOption("--interval-minutes ", "定时检查间隔分钟数")
+ .requiredOption("--quiet-start ", "安静时间开始,HH:mm")
+ .requiredOption("--quiet-end ", "安静时间结束,HH:mm;开始晚于结束表示跨夜")
+ .requiredOption("--cooldown-minutes ", "同一灵感冷却分钟数")
+ .requiredOption("--daily-limit ", "每日浮现上限")
+ .requiredOption("--default-snooze-minutes ", "later 默认稍后分钟数")
+ .requiredOption("--statuses ", "候选状态,逗号分隔: inbox | kept")
+ .requiredOption("--tags ", "可选标签筛选,逗号分隔;传空字符串表示不限")
+ .requiredOption("--projects ", "可选项目筛选,逗号分隔;传空字符串表示不限")
+ .addHelpText(
+ "after",
+ `
+示例:
+ $ el inspiration flow settings set --version 1 --enabled true --interval-minutes 180 --quiet-start 22:00 --quiet-end 08:00 --cooldown-minutes 1440 --daily-limit 3 --default-snooze-minutes 120 --statuses inbox,kept --tags "" --projects "" --json
+`
+ )
+).action(
+ action(async (thisCommand, opts: {
+ version: string;
+ enabled: string;
+ intervalMinutes: string;
+ quietStart: string;
+ quietEnd: string;
+ cooldownMinutes: string;
+ dailyLimit: string;
+ defaultSnoozeMinutes: string;
+ statuses: string;
+ tags: string;
+ projects: string;
+ }) => {
+ const statuses = splitCsv(opts.statuses).map((value) => inspirationStatus(value, false));
+ if (statuses.length === 0) throw new CliUsageError("--statuses 至少包含 inbox 或 kept");
+ const settings = await patch(`${inspirationApiPrefix}/flow/settings`, {
+ expectedVersion: inspirationInteger(opts.version, "--version", 1),
+ enabled: inspirationBoolean(opts.enabled, "--enabled"),
+ intervalMinutes: inspirationInteger(opts.intervalMinutes, "--interval-minutes", 1),
+ quietStartMinute: inspirationMinute(opts.quietStart, "--quiet-start"),
+ quietEndMinute: inspirationMinute(opts.quietEnd, "--quiet-end"),
+ cooldownMinutes: inspirationInteger(opts.cooldownMinutes, "--cooldown-minutes"),
+ dailyLimit: inspirationInteger(opts.dailyLimit, "--daily-limit", 1),
+ defaultSnoozeMinutes: inspirationInteger(
+ opts.defaultSnoozeMinutes,
+ "--default-snooze-minutes",
+ 1
+ ),
+ statuses,
+ tags: splitCsv(opts.tags),
+ projects: splitCsv(opts.projects),
+ });
+ printSuccess(thisCommand, settings, () => {
+ console.log(`✓ Flow 设置已保存 v${(settings as any).version}`);
+ });
+ })
+);
+
+withJson(
+ inspirationFlow
+ .command("deliveries")
+ .description("查看 Flow 投递 ledger;不包含灵感正文。")
+ .option("--limit ", "返回数量,范围 1–100", "20")
+ .option("--before ", "surfacedAt 游标,ISO 8601 且包含时区")
+ .addHelpText(
+ "after",
+ `\n示例:\n $ el inspiration flow deliveries --limit 20\n $ el inspiration flow deliveries --before 2026-08-24T12:00:00+08:00 --json\n`
+ )
+).action(
+ action(async (thisCommand, opts: { limit: string; before?: string }) => {
+ const params = new URLSearchParams({
+ limit: String(inspirationLimit(opts.limit, "--limit")),
+ });
+ if (opts.before) params.set("before", opts.before);
+ const result = await api(`${inspirationApiPrefix}/flow/deliveries?${params}`);
+ printSuccess(thisCommand, result, () => {
+ const deliveries = Array.isArray((result as any).deliveries)
+ ? (result as any).deliveries
+ : [];
+ if (deliveries.length === 0) {
+ console.log("暂无 Flow 投递");
+ return;
+ }
+ for (const delivery of deliveries) {
+ console.log(
+ `${delivery.id}\tv${delivery.version}\t${delivery.status}\t${delivery.outcome ?? "-"}\t${delivery.surfacedAt}`
+ );
+ }
+ });
+ })
+);
+
// el screen [date]
const screen = program
.command("screen")
diff --git a/src/core/plugins/registry.ts b/src/core/plugins/registry.ts
index 2eca0de..f3e6f4f 100644
--- a/src/core/plugins/registry.ts
+++ b/src/core/plugins/registry.ts
@@ -1,8 +1,10 @@
import type { PluginDefinition } from "@echolog/plugin-sdk";
+import { inspirationPlugin } from "@echolog/plugin-inspiration";
import { screenTimePlugin } from "@echolog/plugin-screen-time";
import { tmuxStatusPlugin } from "@echolog/plugin-tmux-status";
export const bundledPlugins: readonly PluginDefinition[] = [
+ inspirationPlugin,
screenTimePlugin,
tmuxStatusPlugin,
];
@@ -10,4 +12,7 @@ export const bundledPlugins: readonly PluginDefinition[] = [
export const bundledPluginWebAssets = [{
prefix: "/plugins/screen-time/",
root: "screen-time/web",
+}, {
+ prefix: "/plugins/inspiration/",
+ root: "inspiration/web",
}] as const;
diff --git a/tests/inspiration-capture.test.ts b/tests/inspiration-capture.test.ts
new file mode 100644
index 0000000..929dc01
--- /dev/null
+++ b/tests/inspiration-capture.test.ts
@@ -0,0 +1,425 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import type { PluginHttpRequest, PluginRoute } from "@echolog/plugin-sdk";
+import manifest from "../plugins/inspiration/echolog.plugin.json" with { type: "json" };
+import { migrations } from "../plugins/inspiration/src/migrations.js";
+import {
+ createInspirationRoutes,
+ type InspirationCaptureStore,
+} from "../plugins/inspiration/src/routes.js";
+import {
+ InspirationStoreError,
+ type InspirationPage,
+ type InspirationStoreListFilter,
+} from "../plugins/inspiration/src/store.js";
+import type {
+ CreateInspirationInput,
+ Inspiration,
+ InspirationStatus,
+ UpdateInspirationInput,
+} from "../plugins/inspiration/src/types.js";
+
+const now = new Date("2026-08-24T01:00:00.000Z");
+
+function row(overrides: Partial = {}): Inspiration {
+ return {
+ id: "capture_001",
+ version: 1,
+ content: "A durable idea",
+ tags: ["design"],
+ project: "EchoLog",
+ status: "inbox",
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ lastSurfacedAt: null,
+ ...overrides,
+ };
+}
+
+class MemoryCaptureStore implements InspirationCaptureStore {
+ readonly rows = new Map();
+ lastCreate: CreateInspirationInput | null = null;
+ lastFilter: InspirationStoreListFilter | null = null;
+
+ async create(input: CreateInspirationInput): Promise {
+ this.lastCreate = input;
+ const created = row({
+ id: `capture_${String(this.rows.size + 1).padStart(3, "0")}`,
+ content: input.content,
+ tags: input.tags,
+ project: input.project,
+ status: input.status,
+ });
+ this.rows.set(created.id, created);
+ return created;
+ }
+
+ async get(id: string): Promise {
+ return this.rows.get(id) ?? null;
+ }
+
+ async list(filter: InspirationStoreListFilter): Promise {
+ this.lastFilter = filter;
+ const items = [...this.rows.values()]
+ .filter((item) => filter.includeArchived || item.status !== "archived")
+ .filter((item) => !filter.statuses?.length || filter.statuses.includes(item.status))
+ .filter((item) => filter.project === undefined || item.project === filter.project)
+ .filter((item) => !filter.tags?.length || filter.tags.every((tag) => item.tags.includes(tag)))
+ .filter((item) => !filter.text || item.content.toLowerCase().includes(filter.text.toLowerCase()))
+ .filter((item) => !filter.before || item.createdAt < filter.before)
+ .filter((item) => !filter.after || item.createdAt > filter.after)
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id))
+ .slice(0, filter.limit);
+ return { items, nextCursor: null };
+ }
+
+ async update(id: string, input: UpdateInspirationInput): Promise {
+ const current = this.requireCurrent(id, input.expectedVersion);
+ if (current.status === "archived") {
+ throw new InspirationStoreError(
+ "INSPIRATION_INVALID_STATE",
+ `Inspiration ${id} cannot be updated from status archived`,
+ 409,
+ current.version
+ );
+ }
+ const updated = {
+ ...current,
+ ...(input.content === undefined ? {} : { content: input.content }),
+ ...(input.tags === undefined ? {} : { tags: input.tags }),
+ ...(input.project === undefined ? {} : { project: input.project }),
+ ...(input.status === undefined ? {} : { status: input.status }),
+ version: current.version + 1,
+ updatedAt: new Date(now.getTime() + current.version),
+ };
+ this.rows.set(id, updated);
+ return updated;
+ }
+
+ async archive(id: string, expectedVersion: number): Promise {
+ const current = this.requireCurrent(id, expectedVersion);
+ if (current.status === "archived") {
+ throw new InspirationStoreError(
+ "INSPIRATION_INVALID_STATE",
+ `Inspiration ${id} cannot be archived from status archived`,
+ 409,
+ current.version
+ );
+ }
+ const archived = {
+ ...current,
+ version: current.version + 1,
+ status: "archived" as const,
+ archivedAt: now,
+ updatedAt: now,
+ };
+ this.rows.set(id, archived);
+ return archived;
+ }
+
+ async restore(
+ id: string,
+ expectedVersion: number,
+ status: Exclude
+ ): Promise {
+ const current = this.requireCurrent(id, expectedVersion);
+ if (current.status !== "archived") {
+ throw new InspirationStoreError(
+ "INSPIRATION_INVALID_STATE",
+ `Inspiration ${id} cannot be restored from status ${current.status}`,
+ 409,
+ current.version
+ );
+ }
+ const restored = {
+ ...current,
+ version: current.version + 1,
+ status,
+ archivedAt: null,
+ updatedAt: now,
+ };
+ this.rows.set(id, restored);
+ return restored;
+ }
+
+ private requireCurrent(id: string, expectedVersion: number): Inspiration {
+ const current = this.rows.get(id);
+ if (!current) {
+ throw new InspirationStoreError(
+ "INSPIRATION_NOT_FOUND",
+ `Inspiration ${id} not found`,
+ 404
+ );
+ }
+ if (current.version !== expectedVersion) {
+ throw new InspirationStoreError(
+ "INSPIRATION_VERSION_CONFLICT",
+ `Inspiration ${id} has changed`,
+ 409,
+ current.version
+ );
+ }
+ return current;
+ }
+}
+
+function route(
+ routes: PluginRoute[],
+ method: PluginRoute["method"],
+ path: string
+): PluginRoute {
+ const found = routes.find((candidate) =>
+ candidate.method === method && candidate.path === path
+ );
+ assert.ok(found, `${method} ${path} route is registered`);
+ return found;
+}
+
+async function call(
+ handler: PluginRoute["handler"],
+ partial: Partial = {}
+): Promise {
+ return handler({
+ params: {},
+ query: {},
+ body: undefined,
+ headers: {},
+ ...partial,
+ }, new AbortController().signal);
+}
+
+test("manifest and migrations define one private standalone plugin schema", () => {
+ assert.equal(manifest.id, "inspiration");
+ assert.deepEqual(manifest.permissions, ["database:plugin"]);
+ assert.deepEqual(migrations.map((migration) => migration.name), [
+ "001_inspirations",
+ "002_inspiration_flow_settings",
+ "003_inspiration_flow_deliveries",
+ "004_inspiration_flow_delivery_attempts",
+ ]);
+ const sql = migrations.map((migration) => migration.sql).join("\n");
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS inspirations/);
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS inspiration_flow_settings/);
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS inspiration_flow_deliveries/);
+ assert.match(sql, /dedupe_key[\s\S]*CREATE UNIQUE INDEX/);
+ assert.match(sql, /inspiration_id TEXT NOT NULL REFERENCES inspirations\(id\)/);
+ assert.match(sql, /CHECK \(\(status = 'archived'\) = \(archived_at IS NOT NULL\)\)/);
+ assert.doesNotMatch(sql, /REFERENCES\s+(records|tasks|schedule)/i);
+ assert.doesNotMatch(sql, /\/api\/(schedule|records)/i);
+});
+
+test("capture validates and normalizes input without requiring Core state", async () => {
+ const store = new MemoryCaptureStore();
+ const routes = createInspirationRoutes(() => store);
+ const result = await call(
+ route(routes, "POST", "/api/plugins/inspiration/inspirations").handler,
+ {
+ body: {
+ content: " Build a quiet inbox ",
+ tags: [" Product ", "product", "Ideas"],
+ project: " EchoLog ",
+ },
+ }
+ );
+ assert.equal(result.statusCode, 201);
+ assert.equal(result.body.content, "Build a quiet inbox");
+ assert.deepEqual(result.body.tags, ["ideas", "product"]);
+ assert.deepEqual(store.lastCreate, {
+ content: "Build a quiet inbox",
+ tags: ["ideas", "product"],
+ project: "EchoLog",
+ status: "inbox",
+ });
+});
+
+test("capture routes reject unknown fields and invalid archived creation", async () => {
+ const routes = createInspirationRoutes(() => new MemoryCaptureStore());
+ const handler = route(
+ routes,
+ "POST",
+ "/api/plugins/inspiration/inspirations"
+ ).handler;
+ const unknown = await call(handler, {
+ body: { content: "Idea", scheduleId: "outside-scope" },
+ });
+ assert.equal(unknown.statusCode, 400);
+ assert.equal(unknown.body.code, "INSPIRATION_VALIDATION_ERROR");
+ const archived = await call(handler, {
+ body: { content: "Idea", status: "archived" },
+ });
+ assert.equal(archived.statusCode, 400);
+});
+
+test("list normalizes filters and preserves deterministic history contract", async () => {
+ const store = new MemoryCaptureStore();
+ store.rows.set("capture_001", row());
+ store.rows.set("capture_002", row({
+ id: "capture_002",
+ content: "Another FLOW thought",
+ tags: ["flow", "product"],
+ status: "kept",
+ createdAt: new Date("2026-08-24T02:00:00.000Z"),
+ }));
+ store.rows.set("capture_003", row({
+ id: "capture_003",
+ status: "archived",
+ archivedAt: now,
+ }));
+ const routes = createInspirationRoutes(() => store);
+ const handler = route(
+ routes,
+ "GET",
+ "/api/plugins/inspiration/inspirations"
+ ).handler;
+ const result = await call(handler, {
+ query: {
+ text: "flow",
+ tag: [" Product ", "flow"],
+ project: "EchoLog",
+ status: ["inbox", "kept"],
+ includeArchived: "false",
+ createdAfter: "2026-08-24T00:00:00.000Z",
+ createdBefore: "2026-08-25T00:00:00.000Z",
+ limit: "10",
+ },
+ });
+ assert.deepEqual(result.items.map((item: Inspiration) => item.id), ["capture_002"]);
+ assert.deepEqual(store.lastFilter, {
+ text: "flow",
+ tags: ["flow", "product"],
+ project: "EchoLog",
+ statuses: ["inbox", "kept"],
+ includeArchived: false,
+ limit: 10,
+ before: new Date("2026-08-25T00:00:00.000Z"),
+ after: new Date("2026-08-24T00:00:00.000Z"),
+ });
+});
+
+test("opaque cursor preserves timestamp and id tie-break boundary", async () => {
+ const store = new MemoryCaptureStore();
+ const routes = createInspirationRoutes(() => store);
+ const cursor = Buffer.from(JSON.stringify({
+ createdAt: "2026-08-24T01:00:00.000Z",
+ id: "capture_009",
+ })).toString("base64url");
+ const result = await call(
+ route(routes, "GET", "/api/plugins/inspiration/inspirations").handler,
+ { query: { cursor, limit: "25" } }
+ );
+ assert.deepEqual(result, { items: [], nextCursor: null });
+ assert.equal(store.lastFilter?.before?.toISOString(), "2026-08-24T01:00:00.000Z");
+ assert.equal(store.lastFilter?.beforeId, "capture_009");
+ const invalid = await call(
+ route(routes, "GET", "/api/plugins/inspiration/inspirations").handler,
+ { query: { cursor: "not-a-cursor" } }
+ );
+ assert.equal(invalid.statusCode, 400);
+});
+
+test("version-guarded edits reject stale concurrent updates", async () => {
+ const store = new MemoryCaptureStore();
+ store.rows.set("capture_001", row());
+ const routes = createInspirationRoutes(() => store);
+ const handler = route(
+ routes,
+ "PATCH",
+ "/api/plugins/inspiration/inspirations/:id"
+ ).handler;
+ const first = await call(handler, {
+ params: { id: "capture_001" },
+ body: { expectedVersion: 1, content: "First writer" },
+ });
+ assert.equal(first.version, 2);
+ const stale = await call(handler, {
+ params: { id: "capture_001" },
+ body: { expectedVersion: 1, content: "Stale writer" },
+ });
+ assert.equal(stale.statusCode, 409);
+ assert.deepEqual(stale.body, {
+ error: "Inspiration capture_001 has changed",
+ code: "INSPIRATION_VERSION_CONFLICT",
+ currentVersion: 2,
+ });
+ assert.equal(store.rows.get("capture_001")?.content, "First writer");
+
+ const source = readFileSync(
+ new URL("../plugins/inspiration/src/store.ts", import.meta.url),
+ "utf8"
+ );
+ assert.match(source, /eq\(inspirations\.version, input\.expectedVersion\)/);
+ assert.match(source, /eq\(inspirations\.version, expectedVersion\)/);
+ assert.match(source, /version: sql`\$\{inspirations\.version\} \+ 1`/);
+});
+
+test("archive and restore are explicit versioned lifecycle operations", async () => {
+ const store = new MemoryCaptureStore();
+ store.rows.set("capture_001", row({ status: "kept" }));
+ const routes = createInspirationRoutes(() => store);
+ const archived = await call(
+ route(
+ routes,
+ "POST",
+ "/api/plugins/inspiration/inspirations/:id/archive"
+ ).handler,
+ { params: { id: "capture_001" }, body: { expectedVersion: 1 } }
+ );
+ assert.equal(archived.status, "archived");
+ assert.equal(archived.version, 2);
+ assert.ok(archived.archivedAt);
+
+ const restored = await call(
+ route(
+ routes,
+ "POST",
+ "/api/plugins/inspiration/inspirations/:id/restore"
+ ).handler,
+ {
+ params: { id: "capture_001" },
+ body: { expectedVersion: 2, status: "kept" },
+ }
+ );
+ assert.equal(restored.status, "kept");
+ assert.equal(restored.version, 3);
+ assert.equal(restored.archivedAt, null);
+});
+
+test("get, update, archive, and restore return structured missing/state errors", async () => {
+ const store = new MemoryCaptureStore();
+ store.rows.set("capture_001", row());
+ const routes = createInspirationRoutes(() => store);
+ const missing = await call(
+ route(routes, "GET", "/api/plugins/inspiration/inspirations/:id").handler,
+ { params: { id: "missing_001" } }
+ );
+ assert.equal(missing.statusCode, 404);
+ assert.equal(missing.body.code, "INSPIRATION_NOT_FOUND");
+
+ const invalidRestore = await call(
+ route(
+ routes,
+ "POST",
+ "/api/plugins/inspiration/inspirations/:id/restore"
+ ).handler,
+ { params: { id: "capture_001" }, body: { expectedVersion: 1 } }
+ );
+ assert.equal(invalidRestore.statusCode, 409);
+ assert.equal(invalidRestore.body.code, "INSPIRATION_INVALID_STATE");
+ assert.equal(invalidRestore.body.currentVersion, 1);
+});
+
+test("store query source implements every Capture filter without cross-plugin access", () => {
+ const source = readFileSync(
+ new URL("../plugins/inspiration/src/store.ts", import.meta.url),
+ "utf8"
+ );
+ assert.match(source, /ILIKE/);
+ assert.match(source, /arrayContains\(inspirations\.tags/);
+ assert.match(source, /eq\(inspirations\.project/);
+ assert.match(source, /inArray\(inspirations\.status/);
+ assert.match(source, /ne\(inspirations\.status, "archived"\)/);
+ assert.match(source, /orderBy\(desc\(inspirations\.createdAt\), desc\(inspirations\.id\)\)/);
+ assert.doesNotMatch(source, /\/api\/(schedule|records)|from\((records|tasks)\)/i);
+});
diff --git a/tests/inspiration-clients.test.ts b/tests/inspiration-clients.test.ts
new file mode 100644
index 0000000..3fce375
--- /dev/null
+++ b/tests/inspiration-clients.test.ts
@@ -0,0 +1,467 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer, type IncomingMessage } from "node:http";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import {
+ inspirationCliContribution,
+ renderInspirationDailySummary,
+} from "../plugins/inspiration/src/cli.js";
+import { createPluginWebHost } from "../web/plugin-host.js";
+
+const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+const webModulePath = new URL("../plugins/inspiration/web/index.js", import.meta.url).href;
+
+function escapeText(value: unknown): string {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+function escapeAttribute(value: unknown): string {
+ return escapeText(value).replaceAll("'", "'");
+}
+
+function runCli(configPath: string, args: string[]): Promise<{
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+}> {
+ return new Promise((resolve) => {
+ execFile(
+ join(repoRoot, "node_modules/.bin/tsx"),
+ [join(repoRoot, "src/cli/index.ts"), ...args],
+ {
+ cwd: repoRoot,
+ env: { ...process.env, ECHOLOG_CONFIG_PATH: configPath },
+ },
+ (error, stdout, stderr) => resolve({
+ exitCode: typeof error?.code === "number" ? error.code : 0,
+ stdout,
+ stderr,
+ })
+ );
+ });
+}
+
+async function requestBody(request: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
+ const text = Buffer.concat(chunks).toString("utf8");
+ return text ? JSON.parse(text) : null;
+}
+
+test("Inspiration CLI metadata and daily summary stay aggregate-only", () => {
+ assert.deepEqual(inspirationCliContribution, {
+ command: "inspiration",
+ apiPrefix: "/api/plugins/inspiration",
+ });
+ assert.equal(renderInspirationDailySummary({ captured: 0, surfaced: 0, outcomes: {} }), null);
+ const summary = renderInspirationDailySummary({
+ captured: 2,
+ surfaced: 3,
+ outcomes: { viewed: 1, later: 2 },
+ });
+ assert.equal(summary, "捕捉 2 条,Flow 浮现 3 次。\n结果:查看 1、稍后 2。");
+ assert.equal(summary?.includes("private inspiration body"), false);
+});
+
+test("Inspiration CLI is HTTP-thin and preserves raw JSON success and errors", async () => {
+ const calls: Array<{ method: string; url: string; body: unknown }> = [];
+ const captured = {
+ id: "inspiration-1",
+ version: 1,
+ content: "试试更短的 onboarding",
+ tags: ["product", "ux"],
+ project: null,
+ status: "inbox",
+ createdAt: "2026-08-24T04:00:00.000Z",
+ updatedAt: "2026-08-24T04:00:00.000Z",
+ archivedAt: null,
+ lastSurfacedAt: null,
+ };
+ const conflict = {
+ error: "inspiration version conflict",
+ code: "VERSION_CONFLICT",
+ currentVersion: 2,
+ };
+ const disabled = {
+ error: "Plugin inspiration is disabled",
+ code: "PLUGIN_DISABLED",
+ pluginId: "inspiration",
+ };
+ const server = createServer(async (request, response) => {
+ const body = await requestBody(request);
+ calls.push({ method: request.method ?? "", url: request.url ?? "", body });
+ if (request.url?.endsWith("/conflict")) {
+ response.writeHead(409, { "content-type": "application/json" });
+ response.end(JSON.stringify(conflict));
+ return;
+ }
+ if (request.url === "/api/plugins/inspiration/flow/settings") {
+ response.writeHead(503, { "content-type": "application/json" });
+ response.end(JSON.stringify(disabled));
+ return;
+ }
+ if (request.url === "/api/plugins/inspiration/flow/next") {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify({ candidate: null, explanation: ["daily-limit"] }));
+ return;
+ }
+ response.writeHead(201, { "content-type": "application/json" });
+ response.end(JSON.stringify(captured));
+ });
+ await new Promise((resolve) => server.listen(0, resolve));
+ const address = server.address();
+ assert.ok(address && typeof address === "object");
+ const temporary = await mkdtemp(join(tmpdir(), "echolog-inspiration-cli-"));
+ const configPath = join(temporary, "config.yaml");
+ await writeFile(configPath, `server:\n port: ${address.port}\n host: localhost\n`);
+
+ try {
+ const capture = await runCli(configPath, [
+ "--json",
+ "inspiration",
+ "capture",
+ captured.content,
+ "--tags",
+ "product,ux",
+ ]);
+ assert.equal(capture.exitCode, 0);
+ assert.equal(capture.stderr, "");
+ assert.deepEqual(JSON.parse(capture.stdout), captured);
+ assert.deepEqual(calls[0], {
+ method: "POST",
+ url: "/api/plugins/inspiration/inspirations",
+ body: {
+ content: captured.content,
+ tags: ["product", "ux"],
+ project: null,
+ status: "inbox",
+ },
+ });
+
+ const next = await runCli(configPath, [
+ "inspiration",
+ "flow",
+ "next",
+ "--idempotency-key",
+ "manual-test",
+ "--json",
+ ]);
+ assert.equal(next.exitCode, 0);
+ assert.deepEqual(JSON.parse(next.stdout), {
+ candidate: null,
+ explanation: ["daily-limit"],
+ });
+ assert.deepEqual(calls[1], {
+ method: "POST",
+ url: "/api/plugins/inspiration/flow/next",
+ body: { idempotencyKey: "manual-test" },
+ });
+
+ const failed = await runCli(configPath, [
+ "inspiration",
+ "show",
+ "conflict",
+ "--json",
+ ]);
+ assert.equal(failed.exitCode, 1);
+ assert.equal(failed.stdout, "");
+ assert.deepEqual(JSON.parse(failed.stderr), conflict);
+
+ const unavailable = await runCli(configPath, [
+ "inspiration",
+ "flow",
+ "settings",
+ "--json",
+ ]);
+ assert.equal(unavailable.exitCode, 1);
+ assert.equal(unavailable.stdout, "");
+ assert.deepEqual(JSON.parse(unavailable.stderr), disabled);
+
+ const help = await runCli(configPath, ["inspiration", "flow", "outcome", "--help"]);
+ assert.equal(help.exitCode, 0);
+ assert.match(help.stdout, /viewed \| continued \| kept \| later \| archived/);
+ assert.match(help.stdout, /--delivery-version/);
+ assert.match(help.stdout, /--inspiration-version/);
+ } finally {
+ await new Promise((resolve, reject) => server.close((error) =>
+ error ? reject(error) : resolve()
+ ));
+ await rm(temporary, { recursive: true, force: true });
+ }
+});
+
+test("Inspiration Web contributes only while ready", async () => {
+ let state = "disabled";
+ const host = createPluginWebHost(async (path: string) => {
+ assert.equal(path, "/plugins");
+ return {
+ plugins: [{
+ id: "inspiration",
+ enabled: state !== "disabled",
+ state,
+ webEntry: webModulePath,
+ }],
+ };
+ });
+ const api = async () => ({ items: [] });
+
+ await host.refresh({ api });
+ assert.deepEqual(host.faces(), []);
+ state = "degraded";
+ await host.refresh({ api });
+ assert.deepEqual(host.faces(), []);
+ state = "ready";
+ await host.refresh({ api });
+ assert.deepEqual(host.faces(), [
+ { type: "inspiration-inbox" },
+ { type: "inspiration-flow" },
+ ]);
+ state = "disabled";
+ await host.refresh({ api });
+ assert.deepEqual(host.faces(), []);
+ await host.stop();
+});
+
+test("Inspiration Web uses canonical APIs, escapes DTOs, and delegates Flow policy", async () => {
+ const { activate } = await import(webModulePath);
+ const malicious = '
';
+ const inspiration = {
+ id: "inspiration-1",
+ version: 3,
+ content: malicious,
+ tags: [""],
+ project: 'Project "quoted"',
+ status: "inbox",
+ createdAt: "2026-08-24T04:00:00.000Z",
+ updatedAt: "2026-08-24T04:00:00.000Z",
+ archivedAt: null,
+ lastSurfacedAt: null,
+ };
+ const delivery = {
+ id: "delivery-1",
+ version: 1,
+ attempts: 1,
+ inspirationId: inspiration.id,
+ source: "manual",
+ dedupeKey: "manual:test",
+ status: "sent",
+ outcome: null,
+ surfacedAt: "2026-08-24T05:00:00.000Z",
+ notifiedAt: null,
+ snoozedUntil: null,
+ outcomeAt: null,
+ notificationChannel: null,
+ error: null,
+ createdAt: "2026-08-24T05:00:00.000Z",
+ updatedAt: "2026-08-24T05:00:00.000Z",
+ };
+ const settings = {
+ id: "default",
+ version: 2,
+ enabled: true,
+ intervalMinutes: 180,
+ quietStartMinute: 1320,
+ quietEndMinute: 480,
+ cooldownMinutes: 1440,
+ dailyLimit: 3,
+ defaultSnoozeMinutes: 120,
+ statuses: ["inbox", "kept"],
+ tags: [],
+ projects: [],
+ updatedAt: "2026-08-24T04:00:00.000Z",
+ };
+ const calls: Array<{ path: string; options?: { method?: string; body?: string } }> = [];
+ const api = async (path: string, options?: { method?: string; body?: string }) => {
+ calls.push({ path, options });
+ if (path.includes("/inspirations?") && !options) {
+ return { items: [inspiration], nextCursor: null };
+ }
+ if (path.endsWith("/flow/settings") && !options) return settings;
+ if (path.includes("/flow/deliveries?") && !options) return { deliveries: [delivery] };
+ if (path.endsWith("/flow/next")) {
+ return {
+ candidate: {
+ inspiration,
+ delivery,
+ explanation: ["never surfaced", malicious],
+ duplicate: false,
+ },
+ explanation: [],
+ };
+ }
+ return { ...inspiration, version: inspiration.version + 1 };
+ };
+ const contribution = await activate({ api });
+ const data = await contribution.load();
+ assert.deepEqual(calls.slice(0, 3).map((call) => call.path), [
+ "/plugins/inspiration/inspirations?limit=50&status=inbox&status=kept",
+ "/plugins/inspiration/flow/settings",
+ "/plugins/inspiration/flow/deliveries?limit=20",
+ ]);
+ assert.deepEqual(Object.keys(data).sort(), [
+ "inspirationFlowDeliveries",
+ "inspirationFlowSettings",
+ "inspirationList",
+ ]);
+
+ const inboxHtml = contribution.renderFace(
+ { type: "inspiration-inbox" },
+ { data, esc: escapeText, escA: escapeAttribute }
+ );
+ assert.equal(inboxHtml.includes(malicious), false);
+ assert.equal(inboxHtml.includes(""), false);
+ assert.match(inboxHtml, /<img src=x onerror="alert\(1\)">/);
+ assert.match(inboxHtml, /<script>alert\(2\)<\/script>/);
+
+ const elements: Record = {
+ inspirationNewContent: { value: "new idea" },
+ inspirationNewTags: { value: "Product, UX" },
+ inspirationNewProject: { value: "EchoLog" },
+ inspirationNewError: { textContent: "" },
+ };
+ const $ = (id: string) => elements[id] ?? null;
+ const captureResult = await contribution.handleAction("capture-inspiration", { id: undefined, $ });
+ assert.equal(captureResult.handled, true);
+ assert.deepEqual(calls.at(-1), {
+ path: "/plugins/inspiration/inspirations",
+ options: {
+ method: "POST",
+ body: JSON.stringify({
+ content: "new idea",
+ tags: ["Product", "UX"],
+ project: "EchoLog",
+ status: "inbox",
+ }),
+ },
+ });
+
+ Object.assign(elements, {
+ "inspirationContent:inspiration-1": { value: "edited idea" },
+ "inspirationTags:inspiration-1": { value: "edited,idea" },
+ "inspirationProject:inspiration-1": { value: "Project B" },
+ "inspirationStatus:inspiration-1": { value: "kept" },
+ "inspirationError:inspiration-1": { textContent: "" },
+ });
+ await contribution.handleAction("edit-inspiration", { id: inspiration.id, $ });
+ assert.deepEqual(calls.at(-1), {
+ path: "/plugins/inspiration/inspirations/inspiration-1",
+ options: {
+ method: "PATCH",
+ body: JSON.stringify({
+ expectedVersion: 3,
+ content: "edited idea",
+ tags: ["edited", "idea"],
+ project: "Project B",
+ status: "kept",
+ }),
+ },
+ });
+ await contribution.handleAction("archive-inspiration", { id: inspiration.id, $ });
+ assert.deepEqual(calls.at(-1), {
+ path: "/plugins/inspiration/inspirations/inspiration-1/archive",
+ options: {
+ method: "POST",
+ body: JSON.stringify({ expectedVersion: 3 }),
+ },
+ });
+
+ Object.assign(elements, {
+ inspirationFilterText: { value: "edited" },
+ inspirationFilterTags: { value: "ux, Product" },
+ inspirationFilterProject: { value: "EchoLog" },
+ inspirationFilterInbox: { checked: false },
+ inspirationFilterKept: { checked: true },
+ inspirationFilterArchived: { checked: true },
+ inspirationIncludeArchived: { checked: true },
+ });
+ await contribution.handleAction("filter-inspirations", { id: undefined, $ });
+ await contribution.load();
+ assert.equal(
+ calls.at(-3)?.path,
+ "/plugins/inspiration/inspirations?limit=50&text=edited&tag=ux&tag=Product&project=EchoLog&status=kept&status=archived&includeArchived=true"
+ );
+
+ Object.assign(elements, {
+ inspirationFlowEnabled: { checked: false },
+ inspirationFlowInterval: { value: "240" },
+ inspirationFlowQuietStart: { value: "23:00" },
+ inspirationFlowQuietEnd: { value: "07:30" },
+ inspirationFlowCooldown: { value: "720" },
+ inspirationFlowDailyLimit: { value: "4" },
+ inspirationFlowDefaultSnooze: { value: "60" },
+ inspirationFlowStatusInbox: { checked: true },
+ inspirationFlowStatusKept: { checked: false },
+ inspirationFlowTags: { value: "ux, product" },
+ inspirationFlowProjects: { value: "EchoLog" },
+ inspirationSettingsError: { textContent: "" },
+ });
+ await contribution.handleAction("save-inspiration-settings", { id: undefined, $ });
+ assert.deepEqual(calls.at(-1), {
+ path: "/plugins/inspiration/flow/settings",
+ options: {
+ method: "PATCH",
+ body: JSON.stringify({
+ expectedVersion: 2,
+ enabled: false,
+ intervalMinutes: 240,
+ quietStartMinute: 1380,
+ quietEndMinute: 450,
+ cooldownMinutes: 720,
+ dailyLimit: 4,
+ defaultSnoozeMinutes: 60,
+ statuses: ["inbox"],
+ tags: ["ux", "product"],
+ projects: ["EchoLog"],
+ }),
+ },
+ });
+
+ await contribution.handleAction("next-inspiration", { id: undefined, $ });
+ const flowHtml = contribution.renderFace(
+ { type: "inspiration-flow" },
+ { data, esc: escapeText, escA: escapeAttribute }
+ );
+ assert.equal(flowHtml.includes(malicious), false);
+ assert.match(flowHtml, /<img src=x onerror="alert\(1\)">/);
+ elements.inspirationSnooze = { value: "90" };
+ elements.inspirationFlowError = { textContent: "" };
+ const outcomeResult = await contribution.handleAction("inspiration-outcome-later", {
+ id: delivery.id,
+ $,
+ });
+ assert.equal(outcomeResult.handled, true);
+ assert.deepEqual(calls.at(-1), {
+ path: "/plugins/inspiration/flow/deliveries/delivery-1/outcome",
+ options: {
+ method: "POST",
+ body: JSON.stringify({
+ expectedDeliveryVersion: 1,
+ expectedInspirationVersion: 3,
+ outcome: "later",
+ snoozeMinutes: 90,
+ }),
+ },
+ });
+ assert.equal(calls.every((call) => call.path.startsWith("/plugins/inspiration")), true);
+});
+
+test("Inspiration client sources contain no scheduling conversion surface", async () => {
+ const web = await readFile(join(repoRoot, "plugins/inspiration/web/index.js"), "utf8");
+ const pluginCli = await readFile(join(repoRoot, "plugins/inspiration/src/cli.ts"), "utf8");
+ const rootCli = await readFile(join(repoRoot, "src/cli/index.ts"), "utf8");
+ const start = rootCli.indexOf("type InspirationLifecycleStatus");
+ const end = rootCli.indexOf("// el screen [date]", start);
+ assert.ok(start >= 0 && end > start);
+ const inspirationRegistration = rootCli.slice(start, end);
+ for (const source of [web, pluginCli, inspirationRegistration]) {
+ assert.doesNotMatch(source, /schedule|转为日程|安排日程|日程 API/i);
+ }
+});
diff --git a/tests/inspiration-flow.test.ts b/tests/inspiration-flow.test.ts
new file mode 100644
index 0000000..60dedf9
--- /dev/null
+++ b/tests/inspiration-flow.test.ts
@@ -0,0 +1,510 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { PluginHttpRequest } from "@echolog/plugin-sdk";
+import { createFlowRoutes, validateOutcome, validateSettingsUpdate } from "../plugins/inspiration/src/flow-routes.js";
+import { FlowStoreError, type FlowOutcomeResult, type FlowReserveResult } from "../plugins/inspiration/src/flow-store.js";
+import {
+ createFlowJob,
+ FlowService,
+ scheduledFlowDedupeKey,
+ type FlowPersistence,
+} from "../plugins/inspiration/src/flow.js";
+import {
+ candidateExclusionReasons,
+ isQuietMinute,
+ selectFlowCandidate,
+ type SelectableInspiration,
+} from "../plugins/inspiration/src/selector.js";
+import type {
+ FlowCandidate,
+ FlowDelivery,
+ FlowSettings,
+ Inspiration,
+} from "../plugins/inspiration/src/types.js";
+
+const NOW = new Date("2026-08-24T12:00:00.000Z");
+
+function inspiration(
+ overrides: Partial = {}
+): Inspiration {
+ return {
+ id: "idea-a",
+ version: 1,
+ content: "Build a deterministic inspiration flow",
+ tags: ["product"],
+ project: "echolog",
+ status: "inbox",
+ createdAt: new Date("2026-08-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-08-01T00:00:00.000Z"),
+ archivedAt: null,
+ lastSurfacedAt: null,
+ ...overrides,
+ };
+}
+
+function settings(overrides: Partial = {}): FlowSettings {
+ return {
+ id: "default",
+ version: 1,
+ enabled: true,
+ intervalMinutes: 60,
+ quietStartMinute: 0,
+ quietEndMinute: 0,
+ cooldownMinutes: 60,
+ dailyLimit: 3,
+ defaultSnoozeMinutes: 120,
+ statuses: ["inbox", "kept"],
+ tags: [],
+ projects: [],
+ updatedAt: NOW,
+ ...overrides,
+ };
+}
+
+function delivery(overrides: Partial = {}): FlowDelivery {
+ return {
+ id: "delivery-a",
+ version: 1,
+ attempts: 1,
+ inspirationId: "idea-a",
+ source: "manual",
+ dedupeKey: "manual:request-a",
+ status: "reserved",
+ outcome: null,
+ surfacedAt: NOW,
+ notifiedAt: null,
+ snoozedUntil: null,
+ outcomeAt: null,
+ notificationChannel: null,
+ error: null,
+ createdAt: NOW,
+ updatedAt: NOW,
+ ...overrides,
+ };
+}
+
+function candidate(overrides: Partial = {}): FlowCandidate {
+ return {
+ inspiration: inspiration({ version: 2, lastSurfacedAt: NOW }),
+ delivery: delivery(),
+ explanation: ["selection:never-surfaced-first"],
+ duplicate: false,
+ ...overrides,
+ };
+}
+
+function selectable(
+ inspirationOverrides: Partial = {},
+ snoozedUntil: Date | null = null
+): SelectableInspiration {
+ return { inspiration: inspiration(inspirationOverrides), snoozedUntil };
+}
+
+function reserveResult(value = candidate()): FlowReserveResult {
+ return {
+ candidate: value,
+ explanation: value.explanation,
+ shouldNotify: value.delivery.status === "reserved",
+ };
+}
+
+function outcomeResult(): FlowOutcomeResult {
+ return {
+ delivery: delivery({ version: 2, status: "acted", outcome: "later" }),
+ inspiration: inspiration({ version: 2, lastSurfacedAt: NOW }),
+ };
+}
+
+function persistence(
+ overrides: Partial = {}
+): FlowPersistence {
+ return {
+ async getSettings() {
+ return settings();
+ },
+ async updateSettings() {
+ return settings({ version: 2 });
+ },
+ async reserveNext() {
+ return reserveResult();
+ },
+ async finalizeNotification(_id, _version, result) {
+ return result.delivered
+ ? delivery({
+ version: 2,
+ status: "sent",
+ notifiedAt: result.at,
+ notificationChannel: result.channel,
+ })
+ : delivery({ version: 2, status: "failed", error: result.error });
+ },
+ async listDeliveries() {
+ return [];
+ },
+ async applyOutcome() {
+ return outcomeResult();
+ },
+ async getDailySummary() {
+ return { captured: 0, surfaced: 0, outcomes: {} };
+ },
+ ...overrides,
+ };
+}
+
+test("quiet-hour policy handles daytime, overnight, and disabled ranges", () => {
+ assert.equal(isQuietMinute(10 * 60, 9 * 60, 17 * 60), true);
+ assert.equal(isQuietMinute(18 * 60, 9 * 60, 17 * 60), false);
+ assert.equal(isQuietMinute(23 * 60, 22 * 60, 8 * 60), true);
+ assert.equal(isQuietMinute(7 * 60 + 59, 22 * 60, 8 * 60), true);
+ assert.equal(isQuietMinute(8 * 60, 22 * 60, 8 * 60), false);
+ assert.equal(isQuietMinute(12 * 60, 0, 0), false);
+});
+
+test("manual and scheduled Flow use identical candidate ranking", () => {
+ const candidates = [
+ selectable({
+ id: "surfaced",
+ createdAt: new Date("2026-07-01T00:00:00.000Z"),
+ lastSurfacedAt: new Date("2026-08-01T00:00:00.000Z"),
+ }),
+ selectable({
+ id: "never-b",
+ createdAt: new Date("2026-08-02T00:00:00.000Z"),
+ }),
+ selectable({
+ id: "never-a",
+ createdAt: new Date("2026-08-02T00:00:00.000Z"),
+ }),
+ ];
+ const common = {
+ candidates,
+ settings: settings(),
+ now: NOW,
+ surfacedToday: 0,
+ };
+ assert.equal(
+ selectFlowCandidate({ ...common, source: "manual" }).selected?.inspiration.id,
+ "never-a"
+ );
+ assert.equal(
+ selectFlowCandidate({ ...common, source: "scheduled" }).selected?.inspiration.id,
+ "never-a"
+ );
+});
+
+test("scheduled gates enabled and overnight quiet hours while manual bypasses only those gates", () => {
+ const localNow = new Date(2026, 7, 24, 23, 30);
+ const common = {
+ candidates: [selectable()],
+ settings: settings({
+ enabled: false,
+ quietStartMinute: 22 * 60,
+ quietEndMinute: 8 * 60,
+ }),
+ now: localNow,
+ surfacedToday: 0,
+ };
+ assert.deepEqual(
+ selectFlowCandidate({ ...common, source: "scheduled" }).explanation,
+ ["policy:disabled", "policy:quiet-hours"]
+ );
+ assert.equal(
+ selectFlowCandidate({ ...common, source: "manual" }).selected?.inspiration.id,
+ "idea-a"
+ );
+});
+
+test("daily limit applies to both sources", () => {
+ for (const source of ["manual", "scheduled"] as const) {
+ const result = selectFlowCandidate({
+ candidates: [selectable()],
+ settings: settings({ dailyLimit: 2 }),
+ source,
+ now: NOW,
+ surfacedToday: 2,
+ });
+ assert.equal(result.selected, null);
+ assert.deepEqual(result.explanation, ["policy:daily-limit"]);
+ }
+});
+
+test("selector explains lifecycle, filter, snooze, and cooldown exclusions", () => {
+ assert.deepEqual(
+ candidateExclusionReasons(
+ selectable(
+ {
+ status: "archived",
+ archivedAt: NOW,
+ tags: ["other"],
+ project: "other",
+ lastSurfacedAt: new Date(NOW.getTime() - 10 * 60_000),
+ },
+ new Date(NOW.getTime() + 60_000)
+ ),
+ settings({ tags: ["product"], projects: ["echolog"] }),
+ NOW
+ ),
+ [
+ "lifecycle:archived",
+ "filter:tags",
+ "filter:project",
+ `delivery:snoozed-until:${new Date(NOW.getTime() + 60_000).toISOString()}`,
+ `policy:cooldown-until:${new Date(NOW.getTime() + 50 * 60_000).toISOString()}`,
+ ]
+ );
+
+ const selection = selectFlowCandidate({
+ candidates: [selectable({ status: "archived", archivedAt: NOW })],
+ settings: settings(),
+ source: "manual",
+ now: NOW,
+ surfacedToday: 0,
+ });
+ assert.deepEqual(selection.explanation, [
+ "selection:no-eligible-inspirations",
+ "excluded:idea-a:lifecycle:archived",
+ ]);
+});
+
+test("scheduled bucket keys are stable across repeated polls and vary by interval", () => {
+ const withinBucket = new Date(NOW.getTime() + 30_000);
+ assert.equal(
+ scheduledFlowDedupeKey(NOW, 60),
+ scheduledFlowDedupeKey(withinBucket, 60)
+ );
+ assert.notEqual(
+ scheduledFlowDedupeKey(NOW, 60),
+ scheduledFlowDedupeKey(NOW, 30)
+ );
+});
+
+test("scheduled job is bounded and forwards the Host abort signal", async () => {
+ const observed: AbortSignal[] = [];
+ const service = {
+ async runScheduled(signal: AbortSignal) {
+ observed.push(signal);
+ return { candidate: null, explanation: [] };
+ },
+ } as unknown as FlowService;
+ const job = createFlowJob(service);
+ const controller = new AbortController();
+ await job.run(controller.signal);
+ assert.equal(job.id, "inspiration-flow");
+ assert.equal(job.intervalMs, 60_000);
+ assert.equal(job.timeoutMs, 30_000);
+ assert.deepEqual(observed, [controller.signal]);
+});
+
+test("service sends the narrow notification contract and finalizes the ledger", async () => {
+ const finalized: unknown[] = [];
+ const sent: unknown[] = [];
+ const store = persistence({
+ async finalizeNotification(...args) {
+ finalized.push(args);
+ return delivery({ version: 2, status: "sent", notifiedAt: NOW });
+ },
+ });
+ const service = new FlowService(
+ store,
+ () => ({
+ async send(input) {
+ sent.push(input);
+ return { delivered: true, channel: "local" };
+ },
+ }),
+ () => NOW
+ );
+ const result = await service.nextManual("request-a");
+ assert.equal(result.candidate?.delivery.status, "sent");
+ assert.deepEqual(sent, [{
+ title: "Inspiration",
+ body: "Build a deterministic inspiration flow",
+ dedupeKey: "manual:request-a",
+ data: {
+ pluginId: "inspiration",
+ inspirationId: "idea-a",
+ deliveryId: "delivery-a",
+ },
+ }]);
+ assert.equal(finalized.length, 1);
+ assert.deepEqual((finalized[0] as unknown[]).slice(0, 2), ["delivery-a", 1]);
+});
+
+test("reserved duplicate resumes after restart but sent duplicate is not re-sent", async () => {
+ let sends = 0;
+ let state: "reserved" | "sent" = "reserved";
+ const store = persistence({
+ async reserveNext() {
+ return reserveResult(candidate({
+ duplicate: true,
+ delivery: delivery({ status: state }),
+ }));
+ },
+ async finalizeNotification() {
+ state = "sent";
+ return delivery({ version: 2, status: "sent" });
+ },
+ });
+ const service = new FlowService(store, () => ({
+ async send() {
+ sends += 1;
+ return { delivered: true };
+ },
+ }), () => NOW);
+
+ await service.nextManual("same-request");
+ await service.nextManual("same-request");
+ assert.equal(sends, 1);
+});
+
+test("notification failures are recorded without leaking provider error text", async () => {
+ let finalization: unknown;
+ const service = new FlowService(persistence({
+ async finalizeNotification(_id, _version, result) {
+ finalization = result;
+ return delivery({ version: 2, status: "failed", error: "notifications.send failed" });
+ },
+ }), () => ({
+ async send() {
+ throw new Error("secret provider response and echoed notification body");
+ },
+ }), () => NOW);
+
+ const result = await service.nextManual("failed-request");
+ assert.equal(result.candidate?.delivery.status, "failed");
+ assert.deepEqual(finalization, {
+ delivered: false,
+ error: "notifications.send failed",
+ at: NOW,
+ });
+});
+
+test("abort leaves a durable reservation for a later restart", async () => {
+ let finalized = false;
+ const controller = new AbortController();
+ controller.abort();
+ const service = new FlowService(persistence({
+ async reserveNext() {
+ return reserveResult();
+ },
+ async finalizeNotification() {
+ finalized = true;
+ return delivery();
+ },
+ }), () => ({
+ async send() {
+ assert.fail("notification must not be attempted after abort");
+ },
+ }), () => NOW);
+
+ await assert.rejects(
+ service.nextManual("aborted", controller.signal),
+ (error) => error instanceof Error && error.name === "AbortError"
+ );
+ assert.equal(finalized, false);
+});
+
+test("later calculates delivery snooze without requesting a lifecycle mutation", async () => {
+ let call: unknown[] | undefined;
+ const service = new FlowService(persistence({
+ async applyOutcome(...args) {
+ call = args;
+ return outcomeResult();
+ },
+ }), () => ({ async send() { return { delivered: true }; } }), () => NOW);
+
+ await service.applyOutcome("delivery-a", {
+ expectedDeliveryVersion: 2,
+ expectedInspirationVersion: 2,
+ outcome: "later",
+ snoozeMinutes: 30,
+ });
+ assert.deepEqual(call, [
+ "delivery-a",
+ 2,
+ 2,
+ "later",
+ new Date(NOW.getTime() + 30 * 60_000),
+ NOW,
+ ]);
+});
+
+test("Flow route validators reject schedule actions and stale outcomes map to 409", async () => {
+ assert.deepEqual(validateOutcome({
+ expectedDeliveryVersion: 1,
+ expectedInspirationVersion: 2,
+ outcome: "schedule",
+ }), {
+ ok: false,
+ error: "outcome must be viewed, continued, kept, later, or archived",
+ });
+ assert.equal(validateSettingsUpdate({}).ok, false);
+
+ const service = {
+ async applyOutcome() {
+ throw new FlowStoreError(
+ "VERSION_CONFLICT",
+ "Flow outcome version conflict",
+ 409,
+ 3,
+ 4
+ );
+ },
+ } as unknown as FlowService;
+ const route = createFlowRoutes(() => service).find(
+ (item) => item.path.endsWith("/:id/outcome")
+ )!;
+ const request: PluginHttpRequest = {
+ params: { id: "delivery-a" },
+ query: {},
+ body: {
+ expectedDeliveryVersion: 2,
+ expectedInspirationVersion: 2,
+ outcome: "viewed",
+ },
+ headers: {},
+ };
+ const result = await route.handler(request, new AbortController().signal);
+ assert.deepEqual(result, {
+ statusCode: 409,
+ body: {
+ error: "Flow outcome version conflict",
+ code: "VERSION_CONFLICT",
+ currentDeliveryVersion: 3,
+ currentInspirationVersion: 4,
+ },
+ });
+});
+
+test("settings validation normalizes tags consistently with Capture", () => {
+ const result = validateSettingsUpdate({
+ expectedVersion: 1,
+ enabled: true,
+ intervalMinutes: 60,
+ quietStartMinute: 1_320,
+ quietEndMinute: 480,
+ cooldownMinutes: 60,
+ dailyLimit: 3,
+ defaultSnoozeMinutes: 120,
+ statuses: ["inbox", "kept"],
+ tags: ["Product", "ECHolog"],
+ projects: ["EchoLog"],
+ });
+ assert.equal(result.ok, true);
+ if (result.ok) {
+ assert.deepEqual(result.value.tags, ["echolog", "product"]);
+ assert.deepEqual(result.value.projects, ["EchoLog"]);
+ }
+});
+
+test("Flow exposes only canonical inspiration plugin routes", () => {
+ const paths = createFlowRoutes(() => ({} as FlowService)).map((route) => route.path);
+ assert.deepEqual(paths, [
+ "/api/plugins/inspiration/flow/settings",
+ "/api/plugins/inspiration/flow/settings",
+ "/api/plugins/inspiration/flow/next",
+ "/api/plugins/inspiration/flow/deliveries",
+ "/api/plugins/inspiration/flow/deliveries/:id/outcome",
+ ]);
+ assert.equal(paths.some((path) => path.includes("schedule")), false);
+});
diff --git a/tests/inspiration.integration.ts b/tests/inspiration.integration.ts
new file mode 100644
index 0000000..61fb73e
--- /dev/null
+++ b/tests/inspiration.integration.ts
@@ -0,0 +1,248 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import test from "node:test";
+import postgres from "postgres";
+import { FlowStore } from "../plugins/inspiration/src/flow-store.js";
+import { migrations } from "../plugins/inspiration/src/migrations.js";
+import {
+ InspirationStore,
+ InspirationStoreError,
+} from "../plugins/inspiration/src/store.js";
+import { createPluginMigrationRunner } from "../src/core/plugins/migrations.js";
+
+const testDatabaseUrl = process.env.ECHOLOG_TEST_DATABASE_URL;
+
+function testSchemaName(): string {
+ return `el_test_inspiration_${process.pid}_${randomUUID()
+ .replaceAll("-", "")
+ .slice(0, 12)}`;
+}
+
+function quoteTestSchema(schema: string): string {
+ if (!/^el_test_inspiration_\d+_[a-f0-9]{12}$/.test(schema)) {
+ throw new Error("refusing to use a non-test schema");
+ }
+ return `"${schema}"`;
+}
+
+function databaseUrlForSchema(databaseUrl: string, schema: string): string {
+ const url = new URL(databaseUrl);
+ url.searchParams.set("options", `-c search_path=${schema}`);
+ return url.toString();
+}
+
+test("inspiration integration requires an explicit test database URL", () => {
+ assert.ok(
+ testDatabaseUrl,
+ "set ECHOLOG_TEST_DATABASE_URL to run PostgreSQL integration tests"
+ );
+});
+
+test(
+ "real PostgreSQL enforces optimistic writes, atomic dedupe, snooze isolation, and cross-bucket recovery",
+ { skip: !testDatabaseUrl, timeout: 30_000 },
+ async () => {
+ if (!testDatabaseUrl) return;
+
+ const schema = testSchemaName();
+ const quotedSchema = quoteTestSchema(schema);
+ const scopedDatabaseUrl = databaseUrlForSchema(testDatabaseUrl, schema);
+ const admin = postgres(testDatabaseUrl, { max: 1 });
+ const blocker = postgres(scopedDatabaseUrl, { max: 1 });
+ const captureStores: InspirationStore[] = [];
+ const flowStores: FlowStore[] = [];
+ let schemaCreated = false;
+
+ try {
+ await admin.unsafe(`CREATE SCHEMA ${quotedSchema}`);
+ schemaCreated = true;
+ const migrationRunner = createPluginMigrationRunner(scopedDatabaseUrl);
+ await migrationRunner("inspiration", migrations);
+ await migrationRunner("inspiration", migrations);
+
+ const captureA = new InspirationStore(scopedDatabaseUrl);
+ const captureB = new InspirationStore(scopedDatabaseUrl);
+ const flowA = new FlowStore(scopedDatabaseUrl);
+ const flowB = new FlowStore(scopedDatabaseUrl);
+ captureStores.push(captureA, captureB);
+ flowStores.push(flowA, flowB);
+
+ const first = await captureA.create({
+ content: "first durable idea",
+ tags: ["flow"],
+ project: "EchoLog",
+ status: "inbox",
+ });
+ const writes = await Promise.allSettled([
+ captureA.update(first.id, {
+ expectedVersion: first.version,
+ content: "winner A",
+ }),
+ captureB.update(first.id, {
+ expectedVersion: first.version,
+ content: "winner B",
+ }),
+ ]);
+ assert.equal(writes.filter((result) => result.status === "fulfilled").length, 1);
+ const rejected = writes.find((result) => result.status === "rejected");
+ assert.ok(rejected?.status === "rejected");
+ assert.ok(rejected.reason instanceof InspirationStoreError);
+ assert.equal(rejected.reason.code, "INSPIRATION_VERSION_CONFLICT");
+
+ await captureA.create({
+ content: "second durable idea",
+ tags: ["flow"],
+ project: "EchoLog",
+ status: "inbox",
+ });
+ const initialSettings = await flowA.getSettings();
+ const configured = await flowA.updateSettings({
+ expectedVersion: initialSettings.version,
+ enabled: true,
+ intervalMinutes: 60,
+ quietStartMinute: 0,
+ quietEndMinute: 0,
+ cooldownMinutes: 0,
+ dailyLimit: 100,
+ defaultSnoozeMinutes: 120,
+ statuses: ["inbox", "kept"],
+ tags: [],
+ projects: [],
+ });
+ assert.equal(configured?.version, initialSettings.version + 1);
+
+ const manualNow = new Date("2026-08-24T08:00:00.000Z");
+ const reservations = await Promise.all([
+ flowA.reserveNext("manual", "manual:postgres-race", manualNow),
+ flowB.reserveNext("manual", "manual:postgres-race", manualNow),
+ ]);
+ assert.equal(reservations.filter((result) => result.shouldNotify).length, 1);
+ assert.equal(
+ new Set(reservations.map((result) => result.candidate?.delivery.id)).size,
+ 1
+ );
+ const owner = reservations.find((result) => result.shouldNotify);
+ assert.ok(owner?.candidate);
+ const sent = await flowA.finalizeNotification(
+ owner.candidate.delivery.id,
+ owner.candidate.delivery.version,
+ { delivered: true, channel: "integration", at: manualNow }
+ );
+ assert.equal(sent.status, "sent");
+ assert.equal(sent.attempts, 1);
+
+ const statusBeforeLater = owner.candidate.inspiration.status;
+ const later = await flowA.applyOutcome(
+ sent.id,
+ sent.version,
+ owner.candidate.inspiration.version,
+ "later",
+ new Date(manualNow.getTime() + 120 * 60_000),
+ manualNow
+ );
+ assert.equal(later.delivery.outcome, "later");
+ assert.equal(later.inspiration.status, statusBeforeLater);
+ assert.equal(
+ later.inspiration.version,
+ owner.candidate.inspiration.version,
+ "later must not mutate inspiration lifecycle/version"
+ );
+
+ const oldScheduledAt = new Date("2026-08-24T10:00:00.000Z");
+ const oldBucket = await flowA.reserveNext(
+ "scheduled",
+ "scheduled:60:old-bucket",
+ oldScheduledAt
+ );
+ assert.equal(oldBucket.shouldNotify, true);
+ assert.ok(oldBucket.candidate);
+ assert.equal(oldBucket.candidate.delivery.status, "reserved");
+ assert.equal(oldBucket.candidate.delivery.attempts, 1);
+
+ const afterBoundary = new Date("2026-08-24T11:01:00.000Z");
+ const recovered = await flowB.reserveNext(
+ "scheduled",
+ "scheduled:60:new-bucket",
+ afterBoundary
+ );
+ assert.equal(recovered.shouldNotify, true);
+ assert.ok(recovered.candidate);
+ assert.equal(recovered.candidate.delivery.id, oldBucket.candidate.delivery.id);
+ assert.equal(recovered.candidate.delivery.dedupeKey, "scheduled:60:old-bucket");
+ assert.equal(recovered.candidate.delivery.attempts, 2);
+ assert.deepEqual(recovered.explanation, ["recovery:pending-delivery"]);
+
+ const finalizedRecovery = await flowB.finalizeNotification(
+ recovered.candidate.delivery.id,
+ recovered.candidate.delivery.version,
+ { delivered: false, error: "notifications.send failed", at: afterBoundary }
+ );
+ assert.equal(finalizedRecovery.status, "failed");
+ assert.equal(finalizedRecovery.attempts, 2);
+
+ const retryAfterFailure = await flowA.reserveNext(
+ "scheduled",
+ "scheduled:60:retry-after-failure",
+ new Date("2026-08-24T12:02:00.000Z")
+ );
+ assert.equal(retryAfterFailure.shouldNotify, true);
+ assert.ok(retryAfterFailure.candidate);
+ assert.notEqual(
+ retryAfterFailure.candidate.delivery.id,
+ finalizedRecovery.id,
+ "a failed attempt must remain eligible for a later dedupe bucket"
+ );
+
+ let releaseSettingsLock!: () => void;
+ let settingsLocked!: () => void;
+ const lockAcquired = new Promise((resolve) => {
+ settingsLocked = resolve;
+ });
+ const releaseLock = new Promise((resolve) => {
+ releaseSettingsLock = resolve;
+ });
+ const heldLock = blocker.begin(async (transaction) => {
+ await transaction`
+ SELECT * FROM inspiration_flow_settings
+ WHERE id = 'default'
+ FOR UPDATE
+ `;
+ settingsLocked();
+ await releaseLock;
+ });
+ await lockAcquired;
+
+ const abortController = new AbortController();
+ const abortedReservation = flowA.reserveNext(
+ "scheduled",
+ "scheduled:60:aborted-lock-wait",
+ new Date("2026-08-24T12:30:00.000Z"),
+ abortController.signal
+ );
+ const abortedAssertion = assert.rejects(
+ abortedReservation,
+ (error) => error instanceof Error && error.name === "AbortError"
+ );
+ abortController.abort();
+ releaseSettingsLock();
+ await heldLock;
+ await abortedAssertion;
+ const abortedRows = await blocker<{ count: number }[]>`
+ SELECT COUNT(*)::int AS count
+ FROM inspiration_flow_deliveries
+ WHERE dedupe_key = 'scheduled:60:aborted-lock-wait'
+ `;
+ assert.equal(abortedRows[0]?.count, 0);
+ } finally {
+ await Promise.all([
+ ...captureStores.map((store) => store.close()),
+ ...flowStores.map((store) => store.close()),
+ ]);
+ if (schemaCreated) {
+ await admin.unsafe(`DROP SCHEMA ${quotedSchema} CASCADE`);
+ }
+ await blocker.end();
+ await admin.end();
+ }
+ }
+);
From 9df75614135c96d7694d96559d17fc9b5c0aabfd Mon Sep 17 00:00:00 2001
From: sevencolor7 <465892377@qq.com>
Date: Mon, 24 Aug 2026 02:38:21 +0800
Subject: [PATCH 07/33] docs(inspiration): finalize task tracking
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7d96d66..e1b060c 100644
--- a/README.md
+++ b/README.md
@@ -188,7 +188,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m
- **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。
- **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。
-- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/08-24-inspiration-plugin/)。
+- **Inspiration**:一个插件的两个阶段——[Issue #33](https://github.com/CubePlus1/echolog/issues/33) 提供无活跃记录也可用的灵感捕捉、Inbox、整理、筛选与归档历史,[Issue #34](https://github.com/CubePlus1/echolog/issues/34) 提供确定性 Flow 回顾、冷却/安静时间/每日上限、稍后与投递账本。它与 Schedule 完全独立,不创建、转换或关联日程;实现上下文见 [Trellis 父任务](.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/)。
插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。
From 6e42f207d4151b6436f123385cb7f250d34eca04 Mon Sep 17 00:00:00 2001
From: sevencolor7 <465892377@qq.com>
Date: Mon, 24 Aug 2026 02:38:44 +0800
Subject: [PATCH 08/33] chore(task): archive inspiration plugin tasks
---
.../2026-08}/08-24-inspiration-capture/check.jsonl | 0
.../{ => archive/2026-08}/08-24-inspiration-capture/design.md | 0
.../2026-08}/08-24-inspiration-capture/implement.jsonl | 0
.../2026-08}/08-24-inspiration-capture/implement.md | 0
.../{ => archive/2026-08}/08-24-inspiration-capture/prd.md | 0
.../{ => archive/2026-08}/08-24-inspiration-capture/task.json | 4 ++--
.../2026-08}/08-24-inspiration-clients/check.jsonl | 0
.../{ => archive/2026-08}/08-24-inspiration-clients/design.md | 0
.../2026-08}/08-24-inspiration-clients/implement.jsonl | 0
.../2026-08}/08-24-inspiration-clients/implement.md | 0
.../{ => archive/2026-08}/08-24-inspiration-clients/prd.md | 0
.../{ => archive/2026-08}/08-24-inspiration-clients/task.json | 4 ++--
.../{ => archive/2026-08}/08-24-inspiration-flow/check.jsonl | 0
.../{ => archive/2026-08}/08-24-inspiration-flow/design.md | 0
.../2026-08}/08-24-inspiration-flow/implement.jsonl | 0
.../{ => archive/2026-08}/08-24-inspiration-flow/implement.md | 0
.../tasks/{ => archive/2026-08}/08-24-inspiration-flow/prd.md | 0
.../{ => archive/2026-08}/08-24-inspiration-flow/task.json | 4 ++--
.../2026-08}/08-24-inspiration-plugin/check.jsonl | 0
.../{ => archive/2026-08}/08-24-inspiration-plugin/design.md | 0
.../2026-08}/08-24-inspiration-plugin/implement.jsonl | 0
.../2026-08}/08-24-inspiration-plugin/implement.md | 0
.../{ => archive/2026-08}/08-24-inspiration-plugin/prd.md | 0
.../08-24-inspiration-plugin/research/plugin-patterns.md | 0
.../{ => archive/2026-08}/08-24-inspiration-plugin/task.json | 4 ++--
25 files changed, 8 insertions(+), 8 deletions(-)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/check.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/design.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/implement.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/implement.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/prd.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-capture/task.json (91%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/check.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/design.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/implement.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/implement.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/prd.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-clients/task.json (91%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/check.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/design.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/implement.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/implement.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/prd.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-flow/task.json (91%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/check.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/design.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/implement.jsonl (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/implement.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/prd.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/research/plugin-patterns.md (100%)
rename .trellis/tasks/{ => archive/2026-08}/08-24-inspiration-plugin/task.json (92%)
diff --git a/.trellis/tasks/08-24-inspiration-capture/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-capture/check.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/check.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-capture/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-capture/design.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/design.md
diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-capture/implement.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-capture/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-capture/implement.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/implement.md
diff --git a/.trellis/tasks/08-24-inspiration-capture/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-capture/prd.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/prd.md
diff --git a/.trellis/tasks/08-24-inspiration-capture/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json
similarity index 91%
rename from .trellis/tasks/08-24-inspiration-capture/task.json
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json
index f091446..22614ad 100644
--- a/.trellis/tasks/08-24-inspiration-capture/task.json
+++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-capture/task.json
@@ -3,7 +3,7 @@
"name": "inspiration-capture",
"title": "Inspiration capture and organization (#33)",
"description": "",
- "status": "in_progress",
+ "status": "completed",
"dev_type": null,
"scope": "plugin package metadata, schema, migrations, capture store/routes/tests",
"package": null,
@@ -11,7 +11,7 @@
"creator": "sc",
"assignee": "sc",
"createdAt": "2026-08-24",
- "completedAt": null,
+ "completedAt": "2026-08-24",
"branch": "codex/inspiration-plugin",
"base_branch": "main",
"worktree_path": null,
diff --git a/.trellis/tasks/08-24-inspiration-clients/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-clients/check.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/check.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-clients/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-clients/design.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/design.md
diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-clients/implement.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-clients/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-clients/implement.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/implement.md
diff --git a/.trellis/tasks/08-24-inspiration-clients/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-clients/prd.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/prd.md
diff --git a/.trellis/tasks/08-24-inspiration-clients/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json
similarity index 91%
rename from .trellis/tasks/08-24-inspiration-clients/task.json
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json
index e9b8f7b..2826f79 100644
--- a/.trellis/tasks/08-24-inspiration-clients/task.json
+++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-clients/task.json
@@ -3,7 +3,7 @@
"name": "inspiration-clients",
"title": "Inspiration CLI Web and report clients",
"description": "",
- "status": "in_progress",
+ "status": "completed",
"dev_type": null,
"scope": "CLI, Web contribution, report-facing helper, client tests",
"package": null,
@@ -11,7 +11,7 @@
"creator": "sc",
"assignee": "sc",
"createdAt": "2026-08-24",
- "completedAt": null,
+ "completedAt": "2026-08-24",
"branch": "codex/inspiration-plugin",
"base_branch": "main",
"worktree_path": null,
diff --git a/.trellis/tasks/08-24-inspiration-flow/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-flow/check.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/check.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-flow/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-flow/design.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/design.md
diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-flow/implement.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-flow/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-flow/implement.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/implement.md
diff --git a/.trellis/tasks/08-24-inspiration-flow/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-flow/prd.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/prd.md
diff --git a/.trellis/tasks/08-24-inspiration-flow/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json
similarity index 91%
rename from .trellis/tasks/08-24-inspiration-flow/task.json
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json
index 9a0fd30..41c781a 100644
--- a/.trellis/tasks/08-24-inspiration-flow/task.json
+++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-flow/task.json
@@ -3,7 +3,7 @@
"name": "inspiration-flow",
"title": "Inspiration Flow surfacing (#34)",
"description": "",
- "status": "in_progress",
+ "status": "completed",
"dev_type": null,
"scope": "selector, flow store/service/routes/job/notification contract/tests",
"package": null,
@@ -11,7 +11,7 @@
"creator": "sc",
"assignee": "sc",
"createdAt": "2026-08-24",
- "completedAt": null,
+ "completedAt": "2026-08-24",
"branch": "codex/inspiration-plugin",
"base_branch": "main",
"worktree_path": null,
diff --git a/.trellis/tasks/08-24-inspiration-plugin/check.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-plugin/check.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/check.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-plugin/design.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-plugin/design.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/design.md
diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.jsonl b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-plugin/implement.jsonl
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.jsonl
diff --git a/.trellis/tasks/08-24-inspiration-plugin/implement.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-plugin/implement.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/implement.md
diff --git a/.trellis/tasks/08-24-inspiration-plugin/prd.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-plugin/prd.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/prd.md
diff --git a/.trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md
similarity index 100%
rename from .trellis/tasks/08-24-inspiration-plugin/research/plugin-patterns.md
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/research/plugin-patterns.md
diff --git a/.trellis/tasks/08-24-inspiration-plugin/task.json b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json
similarity index 92%
rename from .trellis/tasks/08-24-inspiration-plugin/task.json
rename to .trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json
index 1b798f4..02f4a09 100644
--- a/.trellis/tasks/08-24-inspiration-plugin/task.json
+++ b/.trellis/tasks/archive/2026-08/08-24-inspiration-plugin/task.json
@@ -3,7 +3,7 @@
"name": "inspiration-plugin",
"title": "Inspiration bundled plugin",
"description": "",
- "status": "in_progress",
+ "status": "completed",
"dev_type": null,
"scope": "plugins/inspiration + bundled registry/build/docs tracking",
"package": null,
@@ -11,7 +11,7 @@
"creator": "sc",
"assignee": "sc",
"createdAt": "2026-08-24",
- "completedAt": null,
+ "completedAt": "2026-08-24",
"branch": "codex/inspiration-plugin",
"base_branch": "main",
"worktree_path": null,
From e2becaa266f9a6f390ea989a65d633e87fbd7a3c Mon Sep 17 00:00:00 2001
From: sevencolor7 <465892377@qq.com>
Date: Mon, 24 Aug 2026 02:41:14 +0800
Subject: [PATCH 09/33] feat(schedule): add bundled schedule plugin
---
.trellis/spec/backend/database-guidelines.md | 91 ++++
.trellis/spec/frontend/directory-structure.md | 6 +
.../tasks/08-24-schedule-calendar-view/prd.md | 10 +-
.../08-24-schedule-calendar-view/task.json | 2 +-
.trellis/tasks/08-24-schedule-plugin/prd.md | 12 +-
.../tasks/08-24-schedule-plugin/task.json | 2 +-
.../tasks/08-24-schedule-reminders/prd.md | 14 +-
.../tasks/08-24-schedule-reminders/task.json | 2 +-
README.md | 6 +-
config.yaml.example | 5 +
docs/PLUGIN_API.md | 40 ++
package.json | 3 +-
plugins/schedule/README.md | 50 ++
plugins/schedule/config.schema.json | 16 +
plugins/schedule/echolog.plugin.json | 25 +
plugins/schedule/package.json | 34 ++
plugins/schedule/src/index.ts | 184 +++++++
plugins/schedule/src/reminders.ts | 149 +++++
plugins/schedule/src/routes.ts | 185 +++++++
plugins/schedule/src/schema.ts | 135 +++++
plugins/schedule/src/store.ts | 424 +++++++++++++++
plugins/schedule/src/types.ts | 74 +++
plugins/schedule/src/validation.ts | 386 +++++++++++++
plugins/schedule/tsconfig.json | 8 +
plugins/schedule/tsup.config.ts | 10 +
plugins/schedule/web/index.js | 469 ++++++++++++++++
plugins/schedule/web/styles.css | 338 ++++++++++++
pnpm-lock.yaml | 25 +
src/cli/index.ts | 351 ++++++++++++
src/core/plugins/registry.ts | 5 +
tests/schedule-cli.test.ts | 339 ++++++++++++
tests/schedule-web.test.ts | 442 +++++++++++++++
tests/schedule.integration.ts | 392 ++++++++++++++
tests/schedule.test.ts | 512 ++++++++++++++++++
34 files changed, 4722 insertions(+), 24 deletions(-)
create mode 100644 plugins/schedule/README.md
create mode 100644 plugins/schedule/config.schema.json
create mode 100644 plugins/schedule/echolog.plugin.json
create mode 100644 plugins/schedule/package.json
create mode 100644 plugins/schedule/src/index.ts
create mode 100644 plugins/schedule/src/reminders.ts
create mode 100644 plugins/schedule/src/routes.ts
create mode 100644 plugins/schedule/src/schema.ts
create mode 100644 plugins/schedule/src/store.ts
create mode 100644 plugins/schedule/src/types.ts
create mode 100644 plugins/schedule/src/validation.ts
create mode 100644 plugins/schedule/tsconfig.json
create mode 100644 plugins/schedule/tsup.config.ts
create mode 100644 plugins/schedule/web/index.js
create mode 100644 plugins/schedule/web/styles.css
create mode 100644 tests/schedule-cli.test.ts
create mode 100644 tests/schedule-web.test.ts
create mode 100644 tests/schedule.integration.ts
create mode 100644 tests/schedule.test.ts
diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md
index a2fb188..40bc2d5 100644
--- a/.trellis/spec/backend/database-guidelines.md
+++ b/.trellis/spec/backend/database-guidelines.md
@@ -30,6 +30,97 @@ PostgreSQL(docker compose 起在 5436 端口,容器名 echolog-db)+ drizzl
- 主键 TEXT,`nanoid(12)`,应用侧生成
- 时间一律 `TIMESTAMPTZ`;「一天」按服务器本地时区切(`localDateStr()`,`getRecordsByDate` 的 dayStart/dayEnd 模式)
+## Scenario: Plugin-owned scheduled reminders
+
+### 1. Scope / Trigger
+
+- Trigger: a bundled plugin stores scheduled work, polls due rows, calls an
+ external Host service, and must survive duplicate polls or daemon restart.
+- The plugin owns its tables and migration. It must not write Core records or
+ copy the Core notifier.
+
+### 2. Signatures
+
+- Item transitions take `(id, expectedVersion, ...input)` and perform one
+ `UPDATE ... WHERE id = ? AND version = ? AND status IN (...) RETURNING *`.
+- Reminder candidates are exact pairs `(item_id, reminder_at TIMESTAMPTZ)`.
+- The notification boundary is
+ `PluginContext.service("notifications.send")`, accepting
+ `{title, message}` plus an optional `AbortSignal`.
+
+### 3. Contracts
+
+- Store explicit IANA timezone display intent separately from absolute
+ `TIMESTAMPTZ` instants; HTTP inputs must include `Z` or a numeric offset.
+- Derived UI state such as “awaiting confirmation” is calculated from persisted
+ state + time and is never stored as another status.
+- Claim a reminder by inserting a unique ledger key before delivery. A ledger
+ row in any state (`claimed`, `sent`, or `failed`) makes that exact
+ item/reminder instant ineligible for another attempt.
+- At-most-once means a crash after claim may lose one reminder; restart must not
+ repeat a possibly delivered notification. A user action that chooses a new
+ reminder instant creates a new key.
+- Delivery never performs an implicit domain transition. Confirm/start,
+ complete, cancel, and snooze remain explicit versioned mutations.
+
+### 4. Validation & Error Matrix
+
+| Condition | Required behavior |
+|---|---|
+| Missing item | 404 `{error}` |
+| Stale version or invalid state | 409 with `currentVersion` and `currentStatus` |
+| Bare local datetime / invalid IANA zone | 400 `{error}` |
+| Duplicate or restarted poll | Existing ledger excludes the exact instant; no send |
+| Host notification failure | Record bounded failure; do not change item state |
+| Job abort/timeout | Honor the signal, release Host running state, retain claim |
+
+### 5. Good/Base/Bad Cases
+
+- Good: 105 due rows with a batch size of 100 drain as 100 then 5, and a third
+ poll sees 0; all 105 ledger keys are unique.
+- Base: one due item is claimed, notified once, and remains scheduled until an
+ explicit confirmation.
+- Bad: query the oldest 100 due items first, then dedupe in application code.
+ The same ledgered rows occupy every batch and permanently starve row 101.
+
+### 6. Tests Required
+
+- Real PostgreSQL CAS race: two confirmations with one expected version produce
+ exactly one success and one structured 409.
+- Real PostgreSQL poll-limit regression: insert more than one batch, reconstruct
+ the Store between polls, assert every item is attempted once, then assert zero
+ remaining candidates.
+- Assert `claimed`, `sent`, and `failed` ledger rows are all excluded before
+ `LIMIT`; a new snooze instant remains eligible.
+- Assert failed/ignored delivery does not modify status, confirmed timestamp, or
+ create a Core record.
+
+### 7. Wrong vs Correct
+
+#### Wrong
+
+```sql
+SELECT * FROM schedule_items
+WHERE next_reminder_at <= NOW()
+ORDER BY next_reminder_at
+LIMIT 100;
+-- Application code discovers these 100 already have ledger rows.
+```
+
+#### Correct
+
+```sql
+SELECT i.* FROM schedule_items i
+WHERE i.next_reminder_at <= NOW()
+ AND NOT EXISTS (
+ SELECT 1 FROM schedule_reminder_deliveries d
+ WHERE d.item_id = i.id
+ AND d.reminder_at = i.next_reminder_at
+ )
+ORDER BY i.next_reminder_at
+LIMIT 100;
+```
+
## Common Mistakes
- 忘了迁移与 schema.ts 双写,跑起来才发现列不存在
diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md
index 6af4a04..059233d 100644
--- a/.trellis/spec/frontend/directory-structure.md
+++ b/.trellis/spec/frontend/directory-structure.md
@@ -40,3 +40,9 @@ web/
- 翻页手势(wheel/drag)须跳过 `INTERACTIVE` 选择器内的目标
- 重建时加 `.no-anim` 双 rAF 移除,避免翻页动画闪烁
- Chrome 对 `preserve-3d` 翻转背面页的按钮命中不可靠;左页按钮由 `#leftPageHitProxy` 平面透明层接收并按顺序转发给当前 `.leaf.back` 的真实按钮。新增左页按钮时须保持渲染顺序一致,代理层不得保留重复 `id` 或进入键盘焦点序列。
+- 插件可能把同一实体同时渲染到 overview/day 等多个 face,而宿主 `$`
+ 是全局 `document.getElementById`。交互控件 id 与 action target 必须包含
+ face/surface 作用域(例如 `day:`),handler 再安全还原真实
+ id;禁止仅用实体 id 生成控件 id,否则不可见页的同名控件会截获当前页输入。
+ Web 测试须同时渲染两个 face,为两个控件设置不同值,并断言点击某一 face
+ 只读取该 face 的值且 API URL 只编码真实实体 id 一次。
diff --git a/.trellis/tasks/08-24-schedule-calendar-view/prd.md b/.trellis/tasks/08-24-schedule-calendar-view/prd.md
index c6f8a3a..87e0b99 100644
--- a/.trellis/tasks/08-24-schedule-calendar-view/prd.md
+++ b/.trellis/tasks/08-24-schedule-calendar-view/prd.md
@@ -21,13 +21,13 @@ user actions, satisfying GitHub #32 without a second calendar model.
## Acceptance Criteria
-- [ ] Month, week, and day faces render the same fixture items in their correct
+- [x] Month, week, and day faces render the same fixture items in their correct
range/day positions and use item-provided timezone semantics.
-- [ ] Create/confirm/snooze/done/cancel call only canonical routes and include
+- [x] Create/confirm/snooze/done/cancel call only canonical routes and include
the item's current `expectedVersion`.
-- [ ] Notification ignore has no Web-side write; awaiting is derived.
-- [ ] Dynamic text is escaped and stylesheet activation/unmount is tested.
-- [ ] Ready gating is covered by the existing host suite plus Schedule-specific
+- [x] Notification ignore has no Web-side write; awaiting is derived.
+- [x] Dynamic text is escaped and stylesheet activation/unmount is tested.
+- [x] Ready gating is covered by the existing host suite plus Schedule-specific
contribution tests; no module loads for disabled/degraded.
## Dependency
diff --git a/.trellis/tasks/08-24-schedule-calendar-view/task.json b/.trellis/tasks/08-24-schedule-calendar-view/task.json
index 6880820..23cdf8b 100644
--- a/.trellis/tasks/08-24-schedule-calendar-view/task.json
+++ b/.trellis/tasks/08-24-schedule-calendar-view/task.json
@@ -3,7 +3,7 @@
"name": "schedule-calendar-view",
"title": "Schedule calendar views (#32)",
"description": "",
- "status": "planning",
+ "status": "in_progress",
"dev_type": null,
"scope": "frontend",
"package": null,
diff --git a/.trellis/tasks/08-24-schedule-plugin/prd.md b/.trellis/tasks/08-24-schedule-plugin/prd.md
index b5e8f55..074d891 100644
--- a/.trellis/tasks/08-24-schedule-plugin/prd.md
+++ b/.trellis/tasks/08-24-schedule-plugin/prd.md
@@ -42,15 +42,15 @@ cross-child contract and final integration for GitHub Issues #31 and #32.
## Acceptance Criteria
-- [ ] Both child acceptance suites pass and use one `plugins/schedule` package.
-- [ ] README, GitHub #31/#32, and this task tree point to the same branch,
+- [x] Both child acceptance suites pass and use one `plugins/schedule` package.
+- [x] README, GitHub #31/#32, and this task tree point to the same branch,
package, semantics, and verification state.
-- [ ] Web loads Schedule only while the bundled plugin is enabled and `ready`.
-- [ ] Missing `notifications.send` degrades only Schedule; it does not prevent
+- [x] Web loads Schedule only while the bundled plugin is enabled and `ready`.
+- [x] Missing `notifications.send` degrades only Schedule; it does not prevent
Core or another plugin from starting.
-- [ ] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass from the repository
+- [x] `pnpm test`, `pnpm typecheck`, and `pnpm build` pass from the repository
root after integration.
-- [ ] An independent check agent reviews the integrated diff after all three
+- [x] An independent check agent reviews the integrated diff after all three
implementation agents finish, and verified findings are resolved.
- [ ] Changes are committed on `codex/schedule-plugin` without merging any
sibling branch.
diff --git a/.trellis/tasks/08-24-schedule-plugin/task.json b/.trellis/tasks/08-24-schedule-plugin/task.json
index 2b65beb..18b61ef 100644
--- a/.trellis/tasks/08-24-schedule-plugin/task.json
+++ b/.trellis/tasks/08-24-schedule-plugin/task.json
@@ -3,7 +3,7 @@
"name": "schedule-plugin",
"title": "Schedule bundled plugin",
"description": "",
- "status": "planning",
+ "status": "in_progress",
"dev_type": null,
"scope": "cross-layer",
"package": null,
diff --git a/.trellis/tasks/08-24-schedule-reminders/prd.md b/.trellis/tasks/08-24-schedule-reminders/prd.md
index d4c3f51..2941823 100644
--- a/.trellis/tasks/08-24-schedule-reminders/prd.md
+++ b/.trellis/tasks/08-24-schedule-reminders/prd.md
@@ -20,18 +20,18 @@ delivery ledger/job, canonical HTTP API, and `el schedule` client for GitHub #31
## Acceptance Criteria
-- [ ] Migrations create constrained/indexed `schedule_items` and a reminder
+- [x] Migrations create constrained/indexed `schedule_items` and a reminder
ledger with a unique dedupe key; every instant is `TIMESTAMPTZ`.
-- [ ] CRUD/list/range and all state routes validate input and preserve the
+- [x] CRUD/list/range and all state routes validate input and preserve the
parent JSON contract including derived `awaitingConfirmation`.
-- [ ] Two concurrent confirms with the same expected version yield one active
+- [x] Two concurrent confirms with the same expected version yield one active
item and one 409; `confirmedStartAt` reflects the winner's confirmation.
-- [ ] Due polling, repeated polling, daemon/store restart, snooze, abort, and
+- [x] Due polling, repeated polling, daemon/store restart, snooze, abort, and
notification failure have deterministic tests.
-- [ ] Arrival/failed/ignored reminders do not start, complete, cancel, or create
+- [x] Arrival/failed/ignored reminders do not start, complete, cancel, or create
any Core record.
-- [ ] Disabled and missing-service/degraded cases remain isolated by Host tests.
-- [ ] `el schedule` list/show/add/edit/confirm/snooze/done/cancel meets the CLI
+- [x] Disabled and missing-service/degraded cases remain isolated by Host tests.
+- [x] `el schedule` list/show/add/edit/confirm/snooze/done/cancel meets the CLI
agent contract in human and JSON modes.
## Dependency
diff --git a/.trellis/tasks/08-24-schedule-reminders/task.json b/.trellis/tasks/08-24-schedule-reminders/task.json
index cdc6186..611b39f 100644
--- a/.trellis/tasks/08-24-schedule-reminders/task.json
+++ b/.trellis/tasks/08-24-schedule-reminders/task.json
@@ -3,7 +3,7 @@
"name": "schedule-reminders",
"title": "Schedule data and reminders (#31)",
"description": "",
- "status": "planning",
+ "status": "in_progress",
"dev_type": null,
"scope": "backend-cli",
"package": null,
diff --git a/README.md b/README.md
index d3573f5..a28ddcf 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
- **父子任务**:一个大任务可挂多层小任务;服务端防止自指/成环,CLI 与 Web 可创建、查询并查看直接子任务进度
- **笔记**:给任意记录追加 `note | blocker | next`
- **补录与编辑**:`el add --at --for`、`el edit`
-- **内置插件**:screen-time 采样和追溯分类前台应用;tmux-status 通过外部 CLI 提供结构化 pane/资源观测,并以 v3 合约持久化已验证的 Agent conversation↔pane 恢复映射
+- **内置插件**:schedule 提供显式确认的日程提醒与月/周/日视图;screen-time 采样和追溯分类前台应用;tmux-status 通过外部 CLI 提供结构化 pane/资源观测,并以 v3 合约持久化已验证的 Agent conversation↔pane 恢复映射
- **汇总与日报**:今日/指定日汇总、日报 Markdown 生成、可同步到指定目录
- **提醒**(可选):任务超时、空闲提醒、macOS 通知 + ntfy 推送到手机
- **四个入口,一套 REST API**:免构建的 Web 控制台、`el` CLI、本地 stdio MCP、HTTP API(`docs/API.md`)
@@ -90,6 +90,7 @@ el report # 输出日报 Markdown
```bash
el status --json # 今日概览 + 活跃任务
el log --json -n 50 # 历史记录
+el schedule list --json # 日程与明确状态;提醒不会自动开始
el screen --json # 今日屏幕使用(macOS)
el plugins list --json # 内置插件清单与状态
el tmux status --json # tmux-status 原始快照(插件默认禁用)
@@ -109,6 +110,7 @@ el tmux status --json # tmux-status 原始快照(插件默认禁用)
|---|---|
| `server` | 端口(默认 19827)、`apiKey`(本机豁免,非本机必带)、`serveWeb`(false = 纯 API 服务)、`corsOrigins`(跨源白名单,默认不允许跨源) |
| `database` | PostgreSQL 连接(与 docker-compose 默认值对应) |
+| `plugins.schedule` | 日程提醒轮询频率(默认启用);到点只提醒,必须显式确认开始 |
| `plugins.screen-time` | 屏幕采样开关、频率与空闲阈值(默认启用) |
| `plugins.tmux-status` | 外部 executable、超时、采样频率、异常阈值,以及 v3 Agent conversation↔pane 恢复映射(默认禁用) |
| `sync` | 日报 Markdown 同步目标目录 |
@@ -185,7 +187,7 @@ EchoLog Core 通过 Bundled Plugin API v1 托管内置插件。每个插件由 m
- **screen-time**:macOS 前台应用被动采样;按应用和规则聚合今日屏幕使用,Web 可查看分类、维护分类规则,并提供运行时 screen-understanding settings 的版本化 GET/PUT API。历史 `app_usage`、`app_rules` 数据保持兼容。
- **tmux-status**:调用外部 `tmux-status` CLI 获取结构化 pane、资源和状态观测;支持 v1/v2/v3 兼容解析、资源边界校验、幂等同步和已验证的 Agent conversation↔pane 恢复映射。插件默认关闭;不把 CPU、selected pane、进程存活或 pane 前台状态直接当作有效工时,也不保存 prompt、回复正文或 pane 内容。
-- **schedule(开发中)**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/08-24-schedule-plugin/)。
+- **schedule**:以同一套日程数据提供显式确认开始、延后提醒、完成/取消,以及月/周/日视图;到点只提醒,绝不自动启动或创建 Core record。它只通过 Host 的 `notifications.send` 命名服务投递,能力缺失时仅本插件 degraded。实现追踪见 [Issue #31](https://github.com/CubePlus1/echolog/issues/31)、[Issue #32](https://github.com/CubePlus1/echolog/issues/32) 与 [Trellis 父任务](.trellis/tasks/08-24-schedule-plugin/)。
插件清单、生命周期、路由、迁移、Web 贡献和错误处理详见 [Bundled Plugin API v1](docs/PLUGIN_API.md)。Codex 侧的 `$echolog:track-work`、`$echolog:review-work` 和本地 stdio MCP 是独立的集成层,说明见 [Codex Integration](docs/CODEX.md)。
diff --git a/config.yaml.example b/config.yaml.example
index 22fb431..5e43433 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -26,6 +26,11 @@ sync:
auto: false
plugins:
+ schedule:
+ enabled: true
+ config:
+ # 到点只提醒,不会自动开始;轮询由 Host 保证不重入。
+ reminder_poll_seconds: 30
screen-time:
enabled: true
config:
diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md
index 31d438a..c9758cd 100644
--- a/docs/PLUGIN_API.md
+++ b/docs/PLUGIN_API.md
@@ -191,6 +191,46 @@ then delegates data loading, face descriptions, rendering and actions. A module
failure removes only that contribution. Disabled plugins do not add navigation
or pages.
+## Bundled Schedule plugin
+
+`schedule` owns its manifest, configuration, migrations, `schedule_items`,
+reminder delivery ledger, routes, job, CLI, and Web contribution. Its canonical
+routes use `/api/plugins/schedule/*`; `el schedule` and the month/week/day
+views are HTTP clients of those routes.
+
+Canonical routes:
+
+- `GET|POST /api/plugins/schedule/items`
+- `GET|PATCH /api/plugins/schedule/items/:id`
+- `POST /api/plugins/schedule/items/:id/confirm-start`
+- `POST /api/plugins/schedule/items/:id/snooze`
+- `POST /api/plugins/schedule/items/:id/complete`
+- `POST /api/plugins/schedule/items/:id/cancel`
+- `GET /api/plugins/schedule/reminders`
+
+`el schedule` exposes `list`, `show`, `add`, `edit`, `confirm`,
+`snooze`, `done`, and `cancel`; `--json` preserves the API response or
+structured error body.
+
+The plugin requests the exact named service `notifications.send` and declares
+`notifications:send`. Its local consumer contract sends only
+`{ title, message }` plus an optional `AbortSignal`, and receives independent
+`mac` and `ntfy` results with status `sent`, `disabled`, or `failed`.
+Notification configuration and credentials remain Core-owned.
+
+Reaching `scheduledStartAt` only attempts a notification. It never changes
+state or creates/starts a Core record. Only explicit `confirm-start` changes
+`scheduled` to `active`, recording the confirmation time as
+`confirmedStartAt`. Ignoring a reminder changes nothing; snooze changes only
+`nextReminderAt`; completion and cancellation are explicit.
+
+Persisted states are `scheduled | active | done | cancelled`.
+`awaitingConfirmation` is derived from a scheduled item whose planned start is
+not later than now. Month, week, and day views project the same
+`schedule_items` rows; there is no separate calendar event store. All state
+mutations require `expectedVersion`, and each reminder instant is claimed by a
+unique ledger dedupe key before delivery.
+
## Compatibility policy
API v1 changes are additive. A breaking SDK, lifecycle or manifest change
diff --git a/package.json b/package.json
index 0658904..4b1e2e8 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "tsx src/server/app.ts",
- "build": "pnpm --filter @echolog/plugin-sdk build && pnpm --filter @echolog/plugin-screen-time build && pnpm --filter @echolog/plugin-tmux-status build && tsup",
+ "build": "pnpm --filter @echolog/plugin-sdk build && pnpm --filter @echolog/plugin-screen-time build && pnpm --filter @echolog/plugin-tmux-status build && pnpm --filter @echolog/plugin-schedule build && tsup",
"build:macos-capture": "bash scripts/build-macos-capture.sh",
"build:macos-release": "pnpm build && pnpm build:macos-capture",
"package:macos": "bash scripts/package-release.sh --version 0.2.0 --adhoc",
@@ -22,6 +22,7 @@
},
"dependencies": {
"@echolog/plugin-screen-time": "workspace:*",
+ "@echolog/plugin-schedule": "workspace:*",
"@echolog/plugin-sdk": "workspace:*",
"@echolog/plugin-tmux-status": "workspace:*",
"@fastify/cors": "^11.0.0",
diff --git a/plugins/schedule/README.md b/plugins/schedule/README.md
new file mode 100644
index 0000000..e0fecff
--- /dev/null
+++ b/plugins/schedule/README.md
@@ -0,0 +1,50 @@
+# Schedule bundled plugin
+
+Schedule owns planned items, explicit execution state, reminder delivery
+deduplication, the `el schedule` HTTP client, and the Web month/week/day views.
+It is independent of Inspiration and Core records.
+
+```yaml
+plugins:
+ schedule:
+ enabled: true
+ config:
+ reminder_poll_seconds: 30
+```
+
+Reaching `scheduledStartAt` only asks the Host to send a reminder. It never
+starts work or creates a Core record. The persisted states are
+`scheduled | active | done | cancelled`; `awaitingConfirmation` is derived
+for a scheduled item whose planned start has arrived.
+
+- `confirm-start` is the only transition to `active` and records the actual
+ confirmation time in `confirmedStartAt`.
+- Ignoring a notification changes nothing.
+- Snooze changes only `nextReminderAt` plus normal version/update bookkeeping.
+- Complete and cancel are explicit.
+- Every mutation requires the current `expectedVersion`.
+
+The reminder job claims a unique item/reminder-instant ledger key before calling
+`PluginContext.service("notifications.send")`. The manifest declares
+`notifications:send`; Schedule locally consumes only `{title,message}`, an
+optional `AbortSignal`, and per-channel `sent | disabled | failed` results.
+It does not import the Core notifier or access notification configuration.
+Missing service capability degrades only this plugin.
+
+Canonical routes:
+
+- `GET|POST /api/plugins/schedule/items`
+- `GET|PATCH /api/plugins/schedule/items/:id`
+- `POST /api/plugins/schedule/items/:id/confirm-start`
+- `POST /api/plugins/schedule/items/:id/snooze`
+- `POST /api/plugins/schedule/items/:id/complete`
+- `POST /api/plugins/schedule/items/:id/cancel`
+- `GET /api/plugins/schedule/reminders`
+
+`el schedule --help` documents list/show/add/edit/confirm/snooze/done/cancel,
+explicit-offset ISO timestamps, IANA timezones, JSON output, and optimistic
+version handling.
+
+The MVP deliberately excludes recurrence, external calendar sync, AI
+scheduling, notification action callbacks, Inspiration conversion, and Core
+record linkage. Web and CLI provide explicit confirmation and state actions.
diff --git a/plugins/schedule/config.schema.json b/plugins/schedule/config.schema.json
new file mode 100644
index 0000000..d250508
--- /dev/null
+++ b/plugins/schedule/config.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://echolog.local/plugins/schedule/config.schema.json",
+ "title": "Schedule plugin configuration",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "reminder_poll_seconds": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 3600,
+ "default": 30,
+ "description": "How often the Host asks Schedule to claim due reminders"
+ }
+ }
+}
diff --git a/plugins/schedule/echolog.plugin.json b/plugins/schedule/echolog.plugin.json
new file mode 100644
index 0000000..2126e83
--- /dev/null
+++ b/plugins/schedule/echolog.plugin.json
@@ -0,0 +1,25 @@
+{
+ "manifestVersion": 1,
+ "id": "schedule",
+ "version": "1.0.0",
+ "apiVersion": "1",
+ "displayName": "Schedule",
+ "description": "Explicitly confirmed schedules, reminders, and calendar views",
+ "entries": {
+ "server": "./dist/index.js",
+ "web": "/plugins/schedule/index.js"
+ },
+ "capabilities": [
+ "schedule-items",
+ "schedule-reminders",
+ "schedule-calendar"
+ ],
+ "permissions": [
+ "database:plugin",
+ "notifications:send"
+ ],
+ "requires": {
+ "coreApi": "^1.0.0"
+ },
+ "configSchema": "./config.schema.json"
+}
diff --git a/plugins/schedule/package.json b/plugins/schedule/package.json
new file mode 100644
index 0000000..3ba8765
--- /dev/null
+++ b/plugins/schedule/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@echolog/plugin-schedule",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "web",
+ "echolog.plugin.json",
+ "config.schema.json"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@echolog/plugin-sdk": "workspace:*",
+ "drizzle-orm": "^0.44.0",
+ "nanoid": "^5.1.5",
+ "postgres": "^3.4.7"
+ },
+ "devDependencies": {
+ "tsup": "^8.5.0",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/plugins/schedule/src/index.ts b/plugins/schedule/src/index.ts
new file mode 100644
index 0000000..415a46c
--- /dev/null
+++ b/plugins/schedule/src/index.ts
@@ -0,0 +1,184 @@
+import type {
+ PluginDefinition,
+ PluginManifest,
+} from "@echolog/plugin-sdk";
+import manifestJson from "../echolog.plugin.json";
+import { pollDueReminders } from "./reminders.js";
+import { createScheduleRoutes } from "./routes.js";
+import { ScheduleStore } from "./store.js";
+import type { NotificationSend } from "./types.js";
+
+const manifest = manifestJson as PluginManifest;
+let currentStore: ScheduleStore | null = null;
+let notificationSend: NotificationSend | null = null;
+
+export const SCHEDULE_REMINDER_JOB_TIMEOUT_MS = 25_000;
+
+function requireStore(): ScheduleStore {
+ if (!currentStore) throw new Error("schedule store is not initialized");
+ return currentStore;
+}
+
+function requireNotificationSend(): NotificationSend {
+ if (!notificationSend) {
+ throw new Error("schedule notifications service is not initialized");
+ }
+ return notificationSend;
+}
+
+function reminderPollSeconds(config: Readonly>): number {
+ const value = config.reminder_poll_seconds;
+ return typeof value === "number" && Number.isInteger(value) ? value : 30;
+}
+
+export const schedulePlugin: PluginDefinition = {
+ manifest,
+ routes: createScheduleRoutes(requireStore),
+ defaultEnabled: true,
+ defaultConfig: {
+ reminder_poll_seconds: 30,
+ },
+ normalizeConfig(config) {
+ return {
+ reminder_poll_seconds: config.reminder_poll_seconds ?? 30,
+ };
+ },
+ validateConfig(config) {
+ const value = config.reminder_poll_seconds;
+ return typeof value === "number" &&
+ Number.isInteger(value) &&
+ value >= 1 &&
+ value <= 3_600
+ ? []
+ : ["reminder_poll_seconds must be an integer from 1 to 3600"];
+ },
+ migrations: [{
+ name: "001_schedule_items_and_reminder_deliveries",
+ sql: `
+ CREATE TABLE IF NOT EXISTS schedule_items (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ description TEXT,
+ scheduled_start_at TIMESTAMPTZ NOT NULL,
+ scheduled_end_at TIMESTAMPTZ,
+ timezone TEXT NOT NULL,
+ priority INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'scheduled',
+ next_reminder_at TIMESTAMPTZ,
+ confirmed_start_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ cancelled_at TIMESTAMPTZ,
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT schedule_items_title_check
+ CHECK (char_length(btrim(title)) BETWEEN 1 AND 200),
+ CONSTRAINT schedule_items_description_check
+ CHECK (description IS NULL OR char_length(description) <= 5000),
+ CONSTRAINT schedule_items_timezone_check
+ CHECK (char_length(btrim(timezone)) BETWEEN 1 AND 100),
+ CONSTRAINT schedule_items_status_check
+ CHECK (status IN ('scheduled', 'active', 'done', 'cancelled')),
+ CONSTRAINT schedule_items_version_check CHECK (version >= 1),
+ CONSTRAINT schedule_items_priority_check
+ CHECK (priority BETWEEN -1000 AND 1000),
+ CONSTRAINT schedule_items_interval_check
+ CHECK (scheduled_end_at IS NULL OR scheduled_end_at > scheduled_start_at),
+ CONSTRAINT schedule_items_state_timestamps_check CHECK (
+ (status = 'scheduled'
+ AND confirmed_start_at IS NULL
+ AND completed_at IS NULL
+ AND cancelled_at IS NULL)
+ OR (status = 'active'
+ AND confirmed_start_at IS NOT NULL
+ AND completed_at IS NULL
+ AND cancelled_at IS NULL
+ AND next_reminder_at IS NULL)
+ OR (status = 'done'
+ AND completed_at IS NOT NULL
+ AND cancelled_at IS NULL
+ AND next_reminder_at IS NULL)
+ OR (status = 'cancelled'
+ AND completed_at IS NULL
+ AND cancelled_at IS NOT NULL
+ AND next_reminder_at IS NULL)
+ )
+ );
+ CREATE INDEX IF NOT EXISTS idx_schedule_items_status_reminder
+ ON schedule_items(status, next_reminder_at);
+ CREATE INDEX IF NOT EXISTS idx_schedule_items_calendar_range
+ ON schedule_items(scheduled_start_at, scheduled_end_at);
+
+ CREATE TABLE IF NOT EXISTS schedule_reminder_deliveries (
+ id TEXT PRIMARY KEY,
+ dedupe_key TEXT NOT NULL,
+ item_id TEXT NOT NULL REFERENCES schedule_items(id) ON DELETE CASCADE,
+ reminder_at TIMESTAMPTZ NOT NULL,
+ attempted_at TIMESTAMPTZ NOT NULL,
+ completed_at TIMESTAMPTZ,
+ status TEXT NOT NULL,
+ channel_results JSONB,
+ failure TEXT,
+ CONSTRAINT schedule_reminder_deliveries_status_check
+ CHECK (status IN ('claimed', 'sent', 'failed')),
+ CONSTRAINT schedule_reminder_deliveries_failure_check
+ CHECK (failure IS NULL OR char_length(failure) <= 1000),
+ CONSTRAINT schedule_reminder_deliveries_terminal_check CHECK (
+ (status = 'claimed' AND completed_at IS NULL AND channel_results IS NULL)
+ OR (status = 'sent' AND completed_at IS NOT NULL AND channel_results IS NOT NULL)
+ OR (status = 'failed' AND completed_at IS NOT NULL)
+ )
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedule_reminder_deliveries_dedupe_key
+ ON schedule_reminder_deliveries(dedupe_key);
+ CREATE INDEX IF NOT EXISTS idx_schedule_reminder_deliveries_attempted_at
+ ON schedule_reminder_deliveries(attempted_at);
+ `,
+ }],
+ register(context) {
+ currentStore = new ScheduleStore(context.service("database.url"));
+ notificationSend = context.service("notifications.send");
+ context.registerJob({
+ id: "reminder-poll",
+ intervalMs: reminderPollSeconds(context.config) * 1_000,
+ timeoutMs: SCHEDULE_REMINDER_JOB_TIMEOUT_MS,
+ async run(signal) {
+ await pollDueReminders(
+ requireStore(),
+ requireNotificationSend(),
+ signal
+ );
+ },
+ });
+ },
+ start(context) {
+ context.logger.info(
+ { reminderPollSeconds: reminderPollSeconds(context.config) },
+ "Schedule plugin started"
+ );
+ },
+ async stop() {
+ await currentStore?.close();
+ currentStore = null;
+ notificationSend = null;
+ },
+};
+
+export default schedulePlugin;
+
+export { pollDueReminders } from "./reminders.js";
+export { createScheduleRoutes } from "./routes.js";
+export {
+ ScheduleConflictError,
+ ScheduleNotFoundError,
+ ScheduleStore,
+ reminderDedupeKey,
+ scheduleItemFromRow,
+} from "./store.js";
+export type {
+ NotificationSend,
+ NotificationSendResult,
+ ReminderDelivery,
+ ScheduleItem,
+ ScheduleStatus,
+} from "./types.js";
diff --git a/plugins/schedule/src/reminders.ts b/plugins/schedule/src/reminders.ts
new file mode 100644
index 0000000..3e1bdae
--- /dev/null
+++ b/plugins/schedule/src/reminders.ts
@@ -0,0 +1,149 @@
+import type { DueReminder } from "./store.js";
+import type {
+ NotificationChannelResult,
+ NotificationSend,
+ NotificationSendResult,
+ ReminderDelivery,
+} from "./types.js";
+
+export interface ReminderStore {
+ dueReminders(now?: Date, limit?: number): Promise;
+ claimReminder(
+ itemId: string,
+ reminderAt: Date,
+ attemptedAt?: Date
+ ): Promise;
+ finishReminder(
+ id: string,
+ input: {
+ status: "sent" | "failed";
+ channelResults: NotificationSendResult["channels"] | null;
+ failure: string | null;
+ },
+ completedAt?: Date
+ ): Promise;
+}
+
+export interface ReminderPollResult {
+ due: number;
+ claimed: number;
+ sent: number;
+ failed: number;
+ deduplicated: number;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function validChannelResult(value: unknown): value is NotificationChannelResult {
+ if (!value || typeof value !== "object") return false;
+ const result = value as Record;
+ return result.status === "sent" ||
+ result.status === "disabled" ||
+ (result.status === "failed" && typeof result.error === "string");
+}
+
+function validateNotificationResult(value: unknown): NotificationSendResult {
+ if (!value || typeof value !== "object") {
+ throw new Error("notifications.send returned an invalid result");
+ }
+ const channels = (value as { channels?: unknown }).channels;
+ if (!channels || typeof channels !== "object") {
+ throw new Error("notifications.send returned an invalid channels result");
+ }
+ const record = channels as Record;
+ if (!validChannelResult(record.mac) || !validChannelResult(record.ntfy)) {
+ throw new Error("notifications.send must return mac and ntfy channel results");
+ }
+ return {
+ channels: { mac: record.mac, ntfy: record.ntfy },
+ };
+}
+
+function resultOutcome(result: NotificationSendResult): {
+ status: "sent" | "failed";
+ failure: string | null;
+} {
+ const entries = Object.entries(result.channels) as Array<
+ ["mac" | "ntfy", NotificationChannelResult]
+ >;
+ const sent = entries.some(([, channel]) => channel.status === "sent");
+ const details = entries.flatMap(([name, channel]) => {
+ if (channel.status === "failed") return [`${name}: ${channel.error}`];
+ if (channel.status === "disabled") return [`${name}: disabled`];
+ return [];
+ });
+ return {
+ status: sent ? "sent" : "failed",
+ failure: details.length ? details.join("; ") : null,
+ };
+}
+
+function notificationMessage(reminder: DueReminder): string {
+ const item = reminder.item;
+ const description = item.description?.trim();
+ return [
+ `Scheduled for ${item.scheduledStartAt} (${item.timezone}).`,
+ description || null,
+ "Open EchoLog or use el schedule confirm to start explicitly.",
+ ].filter(Boolean).join("\n");
+}
+
+export async function pollDueReminders(
+ store: ReminderStore,
+ send: NotificationSend,
+ signal: AbortSignal,
+ options: { now?: Date; limit?: number } = {}
+): Promise {
+ signal.throwIfAborted();
+ const now = options.now ?? new Date();
+ const due = await store.dueReminders(now, options.limit ?? 100);
+ const summary: ReminderPollResult = {
+ due: due.length,
+ claimed: 0,
+ sent: 0,
+ failed: 0,
+ deduplicated: 0,
+ };
+
+ for (const reminder of due) {
+ signal.throwIfAborted();
+ const claimed = await store.claimReminder(
+ reminder.item.id,
+ reminder.reminderAt,
+ now
+ );
+ if (!claimed) {
+ summary.deduplicated++;
+ continue;
+ }
+ summary.claimed++;
+
+ let result: NotificationSendResult;
+ try {
+ signal.throwIfAborted();
+ result = validateNotificationResult(await send({
+ title: `Schedule reminder: ${reminder.item.title}`,
+ message: notificationMessage(reminder),
+ }, signal));
+ } catch (error) {
+ await store.finishReminder(claimed.id, {
+ status: "failed",
+ channelResults: null,
+ failure: errorMessage(error),
+ }, new Date());
+ summary.failed++;
+ if (signal.aborted) throw error;
+ continue;
+ }
+ const outcome = resultOutcome(result);
+ await store.finishReminder(claimed.id, {
+ status: outcome.status,
+ channelResults: result.channels,
+ failure: outcome.failure,
+ }, new Date());
+ summary[outcome.status]++;
+ }
+ return summary;
+}
diff --git a/plugins/schedule/src/routes.ts b/plugins/schedule/src/routes.ts
new file mode 100644
index 0000000..6471116
--- /dev/null
+++ b/plugins/schedule/src/routes.ts
@@ -0,0 +1,185 @@
+import type {
+ PluginHttpRequest,
+ PluginHttpResponse,
+ PluginRoute,
+} from "@echolog/plugin-sdk";
+import {
+ ScheduleConflictError,
+ ScheduleNotFoundError,
+ type ScheduleStore,
+} from "./store.js";
+import {
+ validateCreateScheduleItem,
+ validateEditScheduleItem,
+ validateExpectedVersionBody,
+ validateItemId,
+ validateListQuery,
+ validateReminderQuery,
+ validateScheduleInterval,
+ validateSnoozeBody,
+} from "./validation.js";
+
+type StoreProvider = () => ScheduleStore;
+
+function response(statusCode: number, body: unknown): PluginHttpResponse {
+ return { statusCode, body };
+}
+
+function scheduleError(error: unknown): PluginHttpResponse {
+ if (error instanceof ScheduleNotFoundError) {
+ return response(404, { error: error.message });
+ }
+ if (error instanceof ScheduleConflictError) {
+ return response(409, {
+ error: error.message,
+ currentVersion: error.metadata.currentVersion,
+ currentStatus: error.metadata.currentStatus,
+ });
+ }
+ throw error;
+}
+
+function invalidItemId(request: PluginHttpRequest): PluginHttpResponse | null {
+ const error = validateItemId(request.params.id);
+ return error ? response(400, { error }) : null;
+}
+
+export function createScheduleRoutes(store: StoreProvider): PluginRoute[] {
+ const prefix = "/api/plugins/schedule";
+ return [
+ {
+ method: "GET",
+ path: `${prefix}/items`,
+ async handler(request) {
+ const validated = validateListQuery(request.query);
+ if (!validated.ok) return response(400, { error: validated.error });
+ return store().list(validated.value);
+ },
+ },
+ {
+ method: "POST",
+ path: `${prefix}/items`,
+ async handler(request) {
+ const validated = validateCreateScheduleItem(request.body);
+ if (!validated.ok) return response(400, { error: validated.error });
+ return response(201, await store().create(validated.value));
+ },
+ },
+ {
+ method: "GET",
+ path: `${prefix}/items/:id`,
+ async handler(request) {
+ const invalid = invalidItemId(request);
+ if (invalid) return invalid;
+ const item = await store().get(request.params.id);
+ return item ?? response(404, {
+ error: `Schedule item ${request.params.id} not found`,
+ });
+ },
+ },
+ {
+ method: "PATCH",
+ path: `${prefix}/items/:id`,
+ async handler(request) {
+ const invalid = invalidItemId(request);
+ if (invalid) return invalid;
+ const validated = validateEditScheduleItem(request.body);
+ if (!validated.ok) return response(400, { error: validated.error });
+ try {
+ const current = await store().get(request.params.id);
+ if (!current) throw new ScheduleNotFoundError(request.params.id);
+ if (
+ current.version === validated.value.expectedVersion &&
+ current.status === "scheduled"
+ ) {
+ const start = validated.value.changes.scheduledStartAt ??
+ new Date(current.scheduledStartAt);
+ const end = Object.hasOwn(validated.value.changes, "scheduledEndAt")
+ ? validated.value.changes.scheduledEndAt ?? null
+ : current.scheduledEndAt
+ ? new Date(current.scheduledEndAt)
+ : null;
+ const intervalError = validateScheduleInterval(start, end);
+ if (intervalError) return response(400, { error: intervalError });
+ }
+ return await store().edit(
+ request.params.id,
+ validated.value.expectedVersion,
+ validated.value.changes
+ );
+ } catch (error) {
+ return scheduleError(error);
+ }
+ },
+ },
+ {
+ method: "POST",
+ path: `${prefix}/items/:id/confirm-start`,
+ async handler(request) {
+ const invalid = invalidItemId(request);
+ if (invalid) return invalid;
+ const validated = validateExpectedVersionBody(request.body);
+ if (!validated.ok) return response(400, { error: validated.error });
+ try {
+ return await store().confirmStart(
+ request.params.id,
+ validated.value.expectedVersion
+ );
+ } catch (error) {
+ return scheduleError(error);
+ }
+ },
+ },
+ {
+ method: "POST",
+ path: `${prefix}/items/:id/snooze`,
+ async handler(request) {
+ const invalid = invalidItemId(request);
+ if (invalid) return invalid;
+ const validated = validateSnoozeBody(request.body);
+ if (!validated.ok) return response(400, { error: validated.error });
+ try {
+ return await store().snooze(
+ request.params.id,
+ validated.value.expectedVersion,
+ validated.value.nextReminderAt
+ );
+ } catch (error) {
+ return scheduleError(error);
+ }
+ },
+ },
+ ...(["complete", "cancel"] as const).map((action): PluginRoute => ({
+ method: "POST",
+ path: `${prefix}/items/:id/${action}`,
+ async handler(request) {
+ const invalid = invalidItemId(request);
+ if (invalid) return invalid;
+ const validated = validateExpectedVersionBody(request.body);
+ if (!validated.ok) return response(400, { error: validated.error });
+ try {
+ return action === "complete"
+ ? await store().complete(
+ request.params.id,
+ validated.value.expectedVersion
+ )
+ : await store().cancel(
+ request.params.id,
+ validated.value.expectedVersion
+ );
+ } catch (error) {
+ return scheduleError(error);
+ }
+ },
+ })),
+ {
+ method: "GET",
+ path: `${prefix}/reminders`,
+ async handler(request) {
+ const validated = validateReminderQuery(request.query);
+ if (!validated.ok) return response(400, { error: validated.error });
+ return store().listReminders(validated.value);
+ },
+ },
+ ];
+}
diff --git a/plugins/schedule/src/schema.ts b/plugins/schedule/src/schema.ts
new file mode 100644
index 0000000..7003774
--- /dev/null
+++ b/plugins/schedule/src/schema.ts
@@ -0,0 +1,135 @@
+import { sql } from "drizzle-orm";
+import {
+ check,
+ index,
+ integer,
+ jsonb,
+ pgTable,
+ text,
+ timestamp,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import type {
+ NotificationSendResult,
+ ReminderDeliveryStatus,
+ ScheduleStatus,
+} from "./types.js";
+
+export const scheduleItems = pgTable(
+ "schedule_items",
+ {
+ id: text("id").primaryKey(),
+ title: text("title").notNull(),
+ description: text("description"),
+ scheduledStartAt: timestamp("scheduled_start_at", { withTimezone: true })
+ .notNull(),
+ scheduledEndAt: timestamp("scheduled_end_at", { withTimezone: true }),
+ timezone: text("timezone").notNull(),
+ priority: integer("priority").notNull().default(0),
+ status: text("status").$type().notNull().default("scheduled"),
+ nextReminderAt: timestamp("next_reminder_at", { withTimezone: true }),
+ confirmedStartAt: timestamp("confirmed_start_at", { withTimezone: true }),
+ completedAt: timestamp("completed_at", { withTimezone: true }),
+ cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
+ version: integer("version").notNull().default(1),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ check(
+ "schedule_items_title_check",
+ sql`char_length(btrim(${table.title})) BETWEEN 1 AND 200`
+ ),
+ check(
+ "schedule_items_description_check",
+ sql`${table.description} IS NULL OR char_length(${table.description}) <= 5000`
+ ),
+ check(
+ "schedule_items_timezone_check",
+ sql`char_length(btrim(${table.timezone})) BETWEEN 1 AND 100`
+ ),
+ check(
+ "schedule_items_status_check",
+ sql`${table.status} IN ('scheduled', 'active', 'done', 'cancelled')`
+ ),
+ check("schedule_items_version_check", sql`${table.version} >= 1`),
+ check(
+ "schedule_items_priority_check",
+ sql`${table.priority} BETWEEN -1000 AND 1000`
+ ),
+ check(
+ "schedule_items_interval_check",
+ sql`${table.scheduledEndAt} IS NULL OR ${table.scheduledEndAt} > ${table.scheduledStartAt}`
+ ),
+ check(
+ "schedule_items_state_timestamps_check",
+ sql`(${table.status} = 'scheduled'
+ AND ${table.confirmedStartAt} IS NULL
+ AND ${table.completedAt} IS NULL
+ AND ${table.cancelledAt} IS NULL)
+ OR (${table.status} = 'active'
+ AND ${table.confirmedStartAt} IS NOT NULL
+ AND ${table.completedAt} IS NULL
+ AND ${table.cancelledAt} IS NULL
+ AND ${table.nextReminderAt} IS NULL)
+ OR (${table.status} = 'done'
+ AND ${table.completedAt} IS NOT NULL
+ AND ${table.cancelledAt} IS NULL
+ AND ${table.nextReminderAt} IS NULL)
+ OR (${table.status} = 'cancelled'
+ AND ${table.completedAt} IS NULL
+ AND ${table.cancelledAt} IS NOT NULL
+ AND ${table.nextReminderAt} IS NULL)`
+ ),
+ index("idx_schedule_items_status_reminder").on(
+ table.status,
+ table.nextReminderAt
+ ),
+ index("idx_schedule_items_calendar_range").on(
+ table.scheduledStartAt,
+ table.scheduledEndAt
+ ),
+ ]
+);
+
+export const scheduleReminderDeliveries = pgTable(
+ "schedule_reminder_deliveries",
+ {
+ id: text("id").primaryKey(),
+ dedupeKey: text("dedupe_key").notNull(),
+ itemId: text("item_id")
+ .notNull()
+ .references(() => scheduleItems.id, { onDelete: "cascade" }),
+ reminderAt: timestamp("reminder_at", { withTimezone: true }).notNull(),
+ attemptedAt: timestamp("attempted_at", { withTimezone: true }).notNull(),
+ completedAt: timestamp("completed_at", { withTimezone: true }),
+ status: text("status").$type().notNull(),
+ channelResults: jsonb("channel_results")
+ .$type(),
+ failure: text("failure"),
+ },
+ (table) => [
+ uniqueIndex("idx_schedule_reminder_deliveries_dedupe_key").on(
+ table.dedupeKey
+ ),
+ index("idx_schedule_reminder_deliveries_attempted_at").on(table.attemptedAt),
+ check(
+ "schedule_reminder_deliveries_status_check",
+ sql`${table.status} IN ('claimed', 'sent', 'failed')`
+ ),
+ check(
+ "schedule_reminder_deliveries_failure_check",
+ sql`${table.failure} IS NULL OR char_length(${table.failure}) <= 1000`
+ ),
+ check(
+ "schedule_reminder_deliveries_terminal_check",
+ sql`(${table.status} = 'claimed' AND ${table.completedAt} IS NULL AND ${table.channelResults} IS NULL)
+ OR (${table.status} = 'sent' AND ${table.completedAt} IS NOT NULL AND ${table.channelResults} IS NOT NULL)
+ OR (${table.status} = 'failed' AND ${table.completedAt} IS NOT NULL)`
+ ),
+ ]
+);
+
+export type ScheduleItemRow = typeof scheduleItems.$inferSelect;
+export type ScheduleReminderDeliveryRow =
+ typeof scheduleReminderDeliveries.$inferSelect;
diff --git a/plugins/schedule/src/store.ts b/plugins/schedule/src/store.ts
new file mode 100644
index 0000000..7287d6f
--- /dev/null
+++ b/plugins/schedule/src/store.ts
@@ -0,0 +1,424 @@
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ gte,
+ gt,
+ inArray,
+ isNotNull,
+ isNull,
+ lte,
+ lt,
+ notExists,
+ or,
+ sql,
+ type SQL,
+} from "drizzle-orm";
+import { drizzle } from "drizzle-orm/postgres-js";
+import { nanoid } from "nanoid";
+import postgres from "postgres";
+import {
+ scheduleItems,
+ scheduleReminderDeliveries,
+ type ScheduleItemRow,
+ type ScheduleReminderDeliveryRow,
+} from "./schema.js";
+import type {
+ CreateScheduleItemInput,
+ EditScheduleItemInput,
+ NotificationSendResult,
+ ReminderDelivery,
+ ScheduleConflictMetadata,
+ ScheduleItem,
+ ScheduleStatus,
+} from "./types.js";
+
+export class ScheduleNotFoundError extends Error {
+ constructor(public readonly itemId: string) {
+ super(`Schedule item ${itemId} not found`);
+ this.name = "ScheduleNotFoundError";
+ }
+}
+
+export class ScheduleConflictError extends Error {
+ constructor(
+ public readonly itemId: string,
+ public readonly expectedVersion: number,
+ public readonly metadata: ScheduleConflictMetadata
+ ) {
+ super(`Schedule item ${itemId} has changed or cannot perform this action`);
+ this.name = "ScheduleConflictError";
+ }
+}
+
+export interface ScheduleListFilter {
+ from?: Date;
+ to?: Date;
+ statuses?: ScheduleStatus[];
+}
+
+export interface DueReminder {
+ item: ScheduleItem;
+ reminderAt: Date;
+}
+
+function iso(value: Date | null): string | null {
+ return value?.toISOString() ?? null;
+}
+
+export function scheduleItemFromRow(
+ row: ScheduleItemRow,
+ now = new Date()
+): ScheduleItem {
+ return {
+ id: row.id,
+ title: row.title,
+ description: row.description,
+ scheduledStartAt: row.scheduledStartAt.toISOString(),
+ scheduledEndAt: iso(row.scheduledEndAt),
+ timezone: row.timezone,
+ priority: row.priority,
+ status: row.status,
+ nextReminderAt: iso(row.nextReminderAt),
+ confirmedStartAt: iso(row.confirmedStartAt),
+ completedAt: iso(row.completedAt),
+ cancelledAt: iso(row.cancelledAt),
+ version: row.version,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ awaitingConfirmation:
+ row.status === "scheduled" && row.scheduledStartAt.getTime() <= now.getTime(),
+ };
+}
+
+function reminderFromRow(row: ScheduleReminderDeliveryRow): ReminderDelivery {
+ return {
+ id: row.id,
+ dedupeKey: row.dedupeKey,
+ itemId: row.itemId,
+ reminderAt: row.reminderAt.toISOString(),
+ attemptedAt: row.attemptedAt.toISOString(),
+ completedAt: iso(row.completedAt),
+ status: row.status,
+ channelResults: row.channelResults,
+ failure: row.failure,
+ };
+}
+
+export function reminderDedupeKey(itemId: string, reminderAt: Date): string {
+ return `schedule:${itemId}:${reminderAt.toISOString()}`;
+}
+
+export class ScheduleStore {
+ private readonly sql;
+ private readonly db;
+ private closed = false;
+
+ constructor(databaseUrl: string) {
+ this.sql = postgres(databaseUrl);
+ this.db = drizzle(this.sql);
+ }
+
+ async close(): Promise {
+ if (this.closed) return;
+ this.closed = true;
+ await this.sql.end();
+ }
+
+ async create(input: CreateScheduleItemInput, now = new Date()): Promise {
+ const [created] = await this.db
+ .insert(scheduleItems)
+ .values({
+ id: nanoid(12),
+ ...input,
+ status: "scheduled",
+ version: 1,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+ if (!created) throw new Error("schedule item was not written");
+ return scheduleItemFromRow(created, now);
+ }
+
+ async get(id: string, now = new Date()): Promise {
+ const row = await this.getRow(id);
+ return row ? scheduleItemFromRow(row, now) : null;
+ }
+
+ private async getRow(id: string): Promise {
+ const [row] = await this.db
+ .select()
+ .from(scheduleItems)
+ .where(eq(scheduleItems.id, id));
+ return row ?? null;
+ }
+
+ async list(filter: ScheduleListFilter = {}, now = new Date()): Promise {
+ const predicates: SQL[] = [];
+ if (filter.to) predicates.push(lt(scheduleItems.scheduledStartAt, filter.to));
+ if (filter.from) {
+ predicates.push(or(
+ gt(scheduleItems.scheduledEndAt, filter.from),
+ and(
+ isNull(scheduleItems.scheduledEndAt),
+ gte(scheduleItems.scheduledStartAt, filter.from)
+ )
+ )!);
+ }
+ if (filter.statuses?.length) {
+ predicates.push(inArray(scheduleItems.status, filter.statuses));
+ }
+ const rows = await this.db
+ .select()
+ .from(scheduleItems)
+ .where(predicates.length ? and(...predicates) : undefined)
+ .orderBy(asc(scheduleItems.scheduledStartAt), desc(scheduleItems.priority));
+ return rows.map((row) => scheduleItemFromRow(row, now));
+ }
+
+ async edit(
+ id: string,
+ expectedVersion: number,
+ changes: EditScheduleItemInput,
+ now = new Date()
+ ): Promise {
+ const explicitlyEditsReminder = Object.hasOwn(changes, "nextReminderAt");
+ const nextReminderAt = explicitlyEditsReminder
+ ? changes.nextReminderAt
+ : changes.scheduledStartAt
+ ? sql`CASE
+ WHEN ${scheduleItems.nextReminderAt} = ${scheduleItems.scheduledStartAt}
+ THEN ${changes.scheduledStartAt.toISOString()}::timestamptz
+ ELSE ${scheduleItems.nextReminderAt}
+ END`
+ : undefined;
+ const [updated] = await this.db
+ .update(scheduleItems)
+ .set({
+ ...changes,
+ ...(nextReminderAt === undefined ? {} : { nextReminderAt }),
+ version: sql`${scheduleItems.version} + 1`,
+ updatedAt: now,
+ })
+ .where(and(
+ eq(scheduleItems.id, id),
+ eq(scheduleItems.version, expectedVersion),
+ eq(scheduleItems.status, "scheduled")
+ ))
+ .returning();
+ if (!updated) await this.throwMutationFailure(id, expectedVersion);
+ return scheduleItemFromRow(updated!, now);
+ }
+
+ async confirmStart(
+ id: string,
+ expectedVersion: number,
+ now = new Date()
+ ): Promise {
+ return this.transition(
+ id,
+ expectedVersion,
+ ["scheduled"],
+ {
+ status: "active",
+ confirmedStartAt: now,
+ nextReminderAt: null,
+ },
+ now
+ );
+ }
+
+ async snooze(
+ id: string,
+ expectedVersion: number,
+ nextReminderAt: Date,
+ now = new Date()
+ ): Promise {
+ return this.transition(
+ id,
+ expectedVersion,
+ ["scheduled"],
+ { nextReminderAt },
+ now
+ );
+ }
+
+ async complete(
+ id: string,
+ expectedVersion: number,
+ now = new Date()
+ ): Promise {
+ return this.transition(
+ id,
+ expectedVersion,
+ ["scheduled", "active"],
+ { status: "done", completedAt: now, nextReminderAt: null },
+ now
+ );
+ }
+
+ async cancel(
+ id: string,
+ expectedVersion: number,
+ now = new Date()
+ ): Promise {
+ return this.transition(
+ id,
+ expectedVersion,
+ ["scheduled", "active"],
+ { status: "cancelled", cancelledAt: now, nextReminderAt: null },
+ now
+ );
+ }
+
+ private async transition(
+ id: string,
+ expectedVersion: number,
+ statuses: ScheduleStatus[],
+ changes: Partial,
+ now: Date
+ ): Promise {
+ const [updated] = await this.db
+ .update(scheduleItems)
+ .set({
+ ...changes,
+ version: sql`${scheduleItems.version} + 1`,
+ updatedAt: now,
+ })
+ .where(and(
+ eq(scheduleItems.id, id),
+ eq(scheduleItems.version, expectedVersion),
+ inArray(scheduleItems.status, statuses)
+ ))
+ .returning();
+ if (!updated) await this.throwMutationFailure(id, expectedVersion);
+ return scheduleItemFromRow(updated!, now);
+ }
+
+ private async throwMutationFailure(
+ id: string,
+ expectedVersion: number
+ ): Promise {
+ const current = await this.getRow(id);
+ if (!current) throw new ScheduleNotFoundError(id);
+ throw new ScheduleConflictError(id, expectedVersion, {
+ currentVersion: current.version,
+ currentStatus: current.status,
+ });
+ }
+
+ async dueReminders(now = new Date(), limit = 100): Promise {
+ const rows = await this.db
+ .select()
+ .from(scheduleItems)
+ .where(and(
+ eq(scheduleItems.status, "scheduled"),
+ isNotNull(scheduleItems.nextReminderAt),
+ lte(scheduleItems.nextReminderAt, now),
+ notExists(
+ this.db
+ .select({ id: scheduleReminderDeliveries.id })
+ .from(scheduleReminderDeliveries)
+ .where(and(
+ eq(scheduleReminderDeliveries.itemId, scheduleItems.id),
+ eq(
+ scheduleReminderDeliveries.reminderAt,
+ scheduleItems.nextReminderAt
+ )
+ ))
+ )
+ ))
+ .orderBy(asc(scheduleItems.nextReminderAt), asc(scheduleItems.id))
+ .limit(limit);
+ return rows.map((row) => ({
+ item: scheduleItemFromRow(row, now),
+ reminderAt: row.nextReminderAt!,
+ }));
+ }
+
+ async claimReminder(
+ itemId: string,
+ reminderAt: Date,
+ attemptedAt = new Date()
+ ): Promise {
+ const id = nanoid(12);
+ const dedupeKey = reminderDedupeKey(itemId, reminderAt);
+ const reminderInstant = reminderAt.toISOString();
+ const attemptedInstant = attemptedAt.toISOString();
+ const inserted = await this.sql.begin(async (transaction) => {
+ // Lock and re-check the item so a stale due-list snapshot cannot claim a
+ // reminder that was already confirmed, cancelled, or snoozed.
+ const eligible = await transaction<{ id: string }[]>`
+ SELECT id
+ FROM schedule_items
+ WHERE id = ${itemId}
+ AND status = 'scheduled'
+ AND next_reminder_at = ${reminderInstant}
+ FOR UPDATE
+ `;
+ if (!eligible[0]) return false;
+ const claimed = await transaction<{ id: string }[]>`
+ INSERT INTO schedule_reminder_deliveries (
+ id, dedupe_key, item_id, reminder_at, attempted_at, status
+ ) VALUES (
+ ${id}, ${dedupeKey}, ${itemId}, ${reminderInstant}, ${attemptedInstant}, 'claimed'
+ )
+ ON CONFLICT (dedupe_key) DO NOTHING
+ RETURNING id
+ `;
+ return Boolean(claimed[0]);
+ });
+ return inserted ? {
+ id,
+ dedupeKey,
+ itemId,
+ reminderAt: reminderAt.toISOString(),
+ attemptedAt: attemptedAt.toISOString(),
+ completedAt: null,
+ status: "claimed",
+ channelResults: null,
+ failure: null,
+ } : null;
+ }
+
+ async finishReminder(
+ id: string,
+ input: {
+ status: "sent" | "failed";
+ channelResults: NotificationSendResult["channels"] | null;
+ failure: string | null;
+ },
+ completedAt = new Date()
+ ): Promise {
+ const [updated] = await this.db
+ .update(scheduleReminderDeliveries)
+ .set({
+ ...input,
+ failure: input.failure?.slice(0, 1_000) ?? null,
+ completedAt,
+ })
+ .where(and(
+ eq(scheduleReminderDeliveries.id, id),
+ eq(scheduleReminderDeliveries.status, "claimed")
+ ))
+ .returning();
+ if (!updated) throw new Error(`Reminder delivery ${id} is not claimable`);
+ return reminderFromRow(updated);
+ }
+
+ async listReminders(
+ filter: { itemId?: string; limit: number }
+ ): Promise {
+ const rows = await this.db
+ .select()
+ .from(scheduleReminderDeliveries)
+ .where(filter.itemId
+ ? eq(scheduleReminderDeliveries.itemId, filter.itemId)
+ : undefined)
+ .orderBy(desc(scheduleReminderDeliveries.attemptedAt))
+ .limit(filter.limit);
+ return rows.map(reminderFromRow);
+ }
+}
diff --git a/plugins/schedule/src/types.ts b/plugins/schedule/src/types.ts
new file mode 100644
index 0000000..e8af091
--- /dev/null
+++ b/plugins/schedule/src/types.ts
@@ -0,0 +1,74 @@
+export type ScheduleStatus = "scheduled" | "active" | "done" | "cancelled";
+
+export interface ScheduleItem {
+ id: string;
+ title: string;
+ description: string | null;
+ scheduledStartAt: string;
+ scheduledEndAt: string | null;
+ timezone: string;
+ priority: number;
+ status: ScheduleStatus;
+ nextReminderAt: string | null;
+ confirmedStartAt: string | null;
+ completedAt: string | null;
+ cancelledAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+ awaitingConfirmation: boolean;
+}
+
+export interface CreateScheduleItemInput {
+ title: string;
+ description: string | null;
+ scheduledStartAt: Date;
+ scheduledEndAt: Date | null;
+ timezone: string;
+ priority: number;
+ nextReminderAt: Date | null;
+}
+
+export interface EditScheduleItemInput {
+ title?: string;
+ description?: string | null;
+ scheduledStartAt?: Date;
+ scheduledEndAt?: Date | null;
+ timezone?: string;
+ priority?: number;
+ nextReminderAt?: Date | null;
+}
+
+export type NotificationChannelResult =
+ | { status: "sent" }
+ | { status: "disabled" }
+ | { status: "failed"; error: string };
+
+export interface NotificationSendResult {
+ channels: Record<"mac" | "ntfy", NotificationChannelResult>;
+}
+
+/** Package-local consumer mirror of the Host's notifications.send contract. */
+export type NotificationSend = (
+ request: { title: string; message: string },
+ signal?: AbortSignal
+) => Promise;
+
+export type ReminderDeliveryStatus = "claimed" | "sent" | "failed";
+
+export interface ReminderDelivery {
+ id: string;
+ dedupeKey: string;
+ itemId: string;
+ reminderAt: string;
+ attemptedAt: string;
+ completedAt: string | null;
+ status: ReminderDeliveryStatus;
+ channelResults: NotificationSendResult["channels"] | null;
+ failure: string | null;
+}
+
+export interface ScheduleConflictMetadata {
+ currentVersion: number;
+ currentStatus: ScheduleStatus;
+}
diff --git a/plugins/schedule/src/validation.ts b/plugins/schedule/src/validation.ts
new file mode 100644
index 0000000..669366b
--- /dev/null
+++ b/plugins/schedule/src/validation.ts
@@ -0,0 +1,386 @@
+import type {
+ CreateScheduleItemInput,
+ EditScheduleItemInput,
+ ScheduleStatus,
+} from "./types.js";
+
+const EXPLICIT_INSTANT_RE =
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/;
+const ITEM_ID_RE = /^[A-Za-z0-9_-]{8,32}$/;
+const STATUSES = new Set([
+ "scheduled",
+ "active",
+ "done",
+ "cancelled",
+]);
+
+export type ValidationResult =
+ | { ok: true; value: T }
+ | { ok: false; error: string };
+
+function objectBody(body: unknown): ValidationResult