@zaclinley/dsh-mailharbor 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -469,11 +469,13 @@ var init_imap = __esm({
469
469
 
470
470
  // src/host/api.ts
471
471
  init_address();
472
- import { randomUUID as randomUUID2 } from "node:crypto";
472
+ import { randomUUID as randomUUID3 } from "node:crypto";
473
473
 
474
474
  // src/host/config.ts
475
475
  import { createHash, randomUUID } from "node:crypto";
476
476
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
477
+ import { homedir as homedir2 } from "node:os";
478
+ import { join as join2 } from "node:path";
477
479
 
478
480
  // src/host/paths.ts
479
481
  import { homedir } from "node:os";
@@ -484,6 +486,14 @@ var ACCOUNTS_DIR = join(DATA_ROOT, "accounts");
484
486
  var STATE_DIR = join(DATA_ROOT, "state");
485
487
  var REPORTS_DIR = join(DATA_ROOT, "reports");
486
488
  var LOGS_DIR = join(DATA_ROOT, "logs");
489
+ function resolveUserPath(value, label) {
490
+ const text2 = value.trim();
491
+ if (text2 === "") throw new Error(`${label}\u4E0D\u80FD\u4E3A\u7A7A`);
492
+ if (text2.length > 4096) throw new Error(`${label}\u8FC7\u957F`);
493
+ const expanded = text2 === "~" ? homedir() : text2.startsWith("~/") ? join(homedir(), text2.slice(2)) : text2;
494
+ if (!isAbsolute(expanded)) throw new Error(`${label}\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84`);
495
+ return expanded;
496
+ }
487
497
  function accountRoot(id) {
488
498
  const safe = id.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown";
489
499
  return join(ACCOUNTS_DIR, safe);
@@ -512,6 +522,7 @@ var DEFAULTS = {
512
522
  },
513
523
  read: { markOnOpen: true, pollSeconds: 60 },
514
524
  render: { listLimit: 40 },
525
+ projects: { root: join2(homedir2(), "work", "projects") },
515
526
  oauth: {
516
527
  google: { clientId: "", redirectPort: 8765, scopes: ["https://mail.google.com/"] },
517
528
  microsoft: {
@@ -625,6 +636,7 @@ function loadConfig() {
625
636
  const aiRaw = file.ai ?? {};
626
637
  const readRaw = file.read ?? {};
627
638
  const renderRaw = file.render ?? {};
639
+ const projectsRaw = file.projects ?? {};
628
640
  const oauthRaw = file.oauth ?? {};
629
641
  const googleRaw = oauthRaw.google ?? {};
630
642
  const microsoftRaw = oauthRaw.microsoft ?? {};
@@ -648,6 +660,9 @@ function loadConfig() {
648
660
  render: {
649
661
  listLimit: intOr(renderRaw.listLimit, DEFAULTS.render.listLimit, 10, 200)
650
662
  },
663
+ projects: {
664
+ root: stringOr(projectsRaw.root, DEFAULTS.projects.root)
665
+ },
651
666
  oauth: {
652
667
  google: {
653
668
  clientId: stringOr(googleRaw.clientId, DEFAULTS.oauth.google.clientId),
@@ -731,7 +746,7 @@ function activeAccount(config) {
731
746
  init_address();
732
747
  import { createHash as createHash2 } from "node:crypto";
733
748
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
734
- import { join as join2 } from "node:path";
749
+ import { join as join3 } from "node:path";
735
750
  import { ImapFlow } from "imapflow";
736
751
  import { simpleParser } from "mailparser";
737
752
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
@@ -961,6 +976,14 @@ var MailDatabase = class {
961
976
  data
962
977
  };
963
978
  }
979
+ /** Locate a local row by Message-ID, for links from tasks and projects. */
980
+ findByMessageId(messageId) {
981
+ const id = messageId.trim().replace(/^<|>$/g, "");
982
+ if (id === "") return void 0;
983
+ const row = this.open().prepare("SELECT id, mailbox FROM emails WHERE message_id = ? LIMIT 1").get(id);
984
+ if (row === void 0) return void 0;
985
+ return { id: Number(row.id), mailbox: String(row.mailbox ?? "") };
986
+ }
964
987
  /** Whether the backup already holds a message with this Message-ID. */
965
988
  hasMessageId(messageId) {
966
989
  const id = messageId.trim().replace(/^<|>$/g, "");
@@ -1058,8 +1081,8 @@ function normalizeId(value) {
1058
1081
  function stamp(value) {
1059
1082
  const date = value instanceof Date ? value : value === void 0 || value === null ? void 0 : new Date(String(value));
1060
1083
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
1061
- const pad = (number) => String(number).padStart(2, "0");
1062
- return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1084
+ const pad2 = (number) => String(number).padStart(2, "0");
1085
+ return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
1063
1086
  }
1064
1087
  function abortIfNeeded(signal) {
1065
1088
  if (signal?.aborted === true) {
@@ -1090,8 +1113,8 @@ var BackupEngine = class {
1090
1113
  }
1091
1114
  writeAttachment(data) {
1092
1115
  const hash = sha256(data);
1093
- const dir = join2(this.attachmentsDir(), hash.slice(0, 2), hash.slice(2, 4));
1094
- const file = join2(dir, hash);
1116
+ const dir = join3(this.attachmentsDir(), hash.slice(0, 2), hash.slice(2, 4));
1117
+ const file = join3(dir, hash);
1095
1118
  mkdirSync2(dir, { recursive: true });
1096
1119
  if (!existsSync3(file)) writeFileSync2(file, data, { mode: 384 });
1097
1120
  return { hash, size: data.length };
@@ -1469,7 +1492,7 @@ var Router = class {
1469
1492
 
1470
1493
  // src/host/ai.ts
1471
1494
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "node:fs";
1472
- import { dirname, join as join3 } from "node:path";
1495
+ import { dirname, join as join4 } from "node:path";
1473
1496
  var TIMEOUT_MS = 9e4;
1474
1497
  var MAX_PROMPT_CHARS = 4e4;
1475
1498
  var SUMMARY_SYSTEM = `\u4F60\u662F\u90AE\u4EF6\u52A9\u7406\uFF0C\u670D\u52A1\u4E8E\u4E2D\u6587\u7528\u6237\u3002\u8BF7\u7528\u7B80\u4F53\u4E2D\u6587\u603B\u7ED3\u90AE\u4EF6\uFF0C\u4E25\u683C\u6309\u4E0B\u9762\u56DB\u6BB5\u8F93\u51FA\uFF0C\u4F7F\u7528\u7ED9\u5B9A\u7684\u5C0F\u6807\u9898\uFF0C\u4E0D\u8981\u8F93\u51FA\u5176\u4ED6\u5185\u5BB9\uFF1A
@@ -1534,11 +1557,21 @@ ${shared}${styleLine}`;
1534
1557
  \u8981\u6C42\uFF1A
1535
1558
  ${shared}${styleLine}`;
1536
1559
  };
1560
+ var TASK_SYSTEM = `\u4F60\u662F\u4EFB\u52A1\u63D0\u53D6\u52A9\u624B\u3002\u4ECE\u90AE\u4EF6\u4E2D\u63D0\u53D6\u9700\u8981\u7528\u6237\u5904\u7406\u7684\u5177\u4F53\u4E8B\u9879\u3002
1561
+
1562
+ \u53EA\u8F93\u51FA\u4E00\u4E2A JSON \u6570\u7EC4\uFF0C\u4E0D\u8981\u8F93\u51FA\u89E3\u91CA\u3001Markdown \u6216\u4EE3\u7801\u5757\u6807\u8BB0\u3002\u6BCF\u4E2A\u5143\u7D20\uFF1A
1563
+ {"title": "\u4EFB\u52A1\u6807\u9898\uFF08\u52A8\u8BCD\u5F00\u5934\uFF0C\u5C3D\u91CF\u5177\u4F53\uFF09", "due": "YYYY-MM-DD \u6216\u7A7A\u5B57\u7B26\u4E32", "priority": 0 \u5230 9 \u7684\u6574\u6570, "owner": "\u8D1F\u8D23\u4EBA\u6216\u7A7A\u5B57\u7B26\u4E32", "notes": "\u4E00\u53E5\u6765\u6E90\u4F9D\u636E"}
1564
+
1565
+ \u89C4\u5219\uFF1A
1566
+ - \u53EA\u63D0\u53D6\u90AE\u4EF6\u91CC\u660E\u786E\u8981\u6C42\u3001\u6216\u660E\u663E\u9700\u8981\u8DDF\u8FDB\u7684\u4E8B\u9879
1567
+ - \u622A\u6B62\u65F6\u95F4\u80FD\u786E\u5B9A\u624D\u586B\uFF0C\u5426\u5219\u7559\u7A7A
1568
+ - \u6CA1\u6709\u53EF\u6267\u884C\u4E8B\u9879\u65F6\u8F93\u51FA []
1569
+ - \u4E0D\u8981\u7F16\u9020\u90AE\u4EF6\u91CC\u6CA1\u6709\u7684\u4FE1\u606F`;
1537
1570
  var STYLE_SYSTEM = `\u4F60\u662F\u5199\u4F5C\u98CE\u683C\u5206\u6790\u5E08\u3002\u9605\u8BFB\u7528\u6237\u6700\u8FD1\u53D1\u51FA\u7684\u90AE\u4EF6\uFF0C\u7528\u7B80\u4F53\u4E2D\u6587\u5199\u4E00\u6BB5 150-300 \u5B57\u7684\u5199\u4F5C\u98CE\u683C\u6863\u6848\uFF0C\u8986\u76D6\uFF1A
1538
1571
  \u5E38\u7528\u79F0\u547C\u3001\u5F00\u5934\u65B9\u5F0F\u3001\u7ED3\u5C3E\u4E0E\u7F72\u540D\u3001\u8BED\u6C14\uFF08\u6B63\u5F0F/\u4EB2\u5207/\u76F4\u63A5\uFF09\u3001\u53E5\u5B50\u957F\u77ED\u3001\u5E38\u7528\u8868\u8FBE\u6216\u53E3\u5934\u7985\u3002
1539
1572
  \u53EA\u8F93\u51FA\u6863\u6848\u6B63\u6587\uFF0C\u4E0D\u8981\u8F93\u51FA\u6807\u9898\u3001\u5217\u8868\u7B26\u53F7\u6216\u5206\u6790\u8FC7\u7A0B\u3002`;
1540
1573
  function stylePath(root) {
1541
- return join3(root, "style.json");
1574
+ return join4(root, "style.json");
1542
1575
  }
1543
1576
  function readStyle(root) {
1544
1577
  try {
@@ -1710,6 +1743,11 @@ ${clip(item, 4e3)}`).join("\n\n");
1710
1743
  ].join("\n");
1711
1744
  return this.complete(ASK_SYSTEM, user, signal);
1712
1745
  }
1746
+ /** Extract actionable tasks from one mail. */
1747
+ async extractTasks(message, signal) {
1748
+ const text2 = await this.complete(TASK_SYSTEM, contextOf(message), signal);
1749
+ return parseTaskJson(text2);
1750
+ }
1713
1751
  /** Compose-body assistance: draft from points, polish, or shorten. */
1714
1752
  async compose(account, options, signal) {
1715
1753
  const style = this.style(account)?.profile ?? "";
@@ -1768,6 +1806,33 @@ function normalizeTranslated(value) {
1768
1806
  function htmlToPlain(html) {
1769
1807
  return html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|li|tr|h[1-6])>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\n{3,}/g, "\n\n").trim();
1770
1808
  }
1809
+ function parseTaskJson(text2) {
1810
+ const start = text2.indexOf("[");
1811
+ const end = text2.lastIndexOf("]");
1812
+ if (start < 0 || end <= start) return [];
1813
+ let parsed;
1814
+ try {
1815
+ parsed = JSON.parse(text2.slice(start, end + 1));
1816
+ } catch {
1817
+ return [];
1818
+ }
1819
+ if (!Array.isArray(parsed)) return [];
1820
+ const result = [];
1821
+ for (const item of parsed) {
1822
+ if (item === null || typeof item !== "object") continue;
1823
+ const value = item;
1824
+ const title = String(value.title ?? "").trim();
1825
+ if (title === "") continue;
1826
+ result.push({
1827
+ title: title.slice(0, 300),
1828
+ due: /^\d{4}-\d{2}-\d{2}/.test(String(value.due ?? "")) ? String(value.due).slice(0, 10) : "",
1829
+ priority: Math.max(0, Math.min(9, Number(value.priority ?? 0) || 0)),
1830
+ owner: String(value.owner ?? "").slice(0, 120),
1831
+ notes: String(value.notes ?? "").slice(0, 1e3)
1832
+ });
1833
+ }
1834
+ return result.slice(0, 30);
1835
+ }
1771
1836
  function summaryBlocks(text2) {
1772
1837
  const blocks = [];
1773
1838
  let current = { title: "", lines: [] };
@@ -1794,8 +1859,578 @@ function textToHtml(text2) {
1794
1859
  ).join("");
1795
1860
  }
1796
1861
 
1862
+ // src/host/ics.ts
1863
+ var CRLF = "\r\n";
1864
+ function escapeText(value) {
1865
+ return String(value ?? "").replaceAll("\\", "\\\\").replaceAll(";", "\\;").replaceAll(",", "\\,").replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
1866
+ }
1867
+ function unescapeText(value) {
1868
+ return String(value ?? "").replaceAll("\\n", "\n").replaceAll("\\N", "\n").replaceAll("\\,", ",").replaceAll("\\;", ";").replaceAll("\\\\", "\\");
1869
+ }
1870
+ function fold(line2) {
1871
+ const text2 = String(line2);
1872
+ if (text2.length <= 74) return text2;
1873
+ const chunks = [];
1874
+ let rest = text2;
1875
+ while (rest.length > 74) {
1876
+ chunks.push(rest.slice(0, 74));
1877
+ rest = ` ${rest.slice(74)}`;
1878
+ }
1879
+ chunks.push(rest);
1880
+ return chunks.join(CRLF);
1881
+ }
1882
+ function utcStamp(date) {
1883
+ const pad2 = (value) => String(value).padStart(2, "0");
1884
+ return `${String(date.getUTCFullYear())}${pad2(date.getUTCMonth() + 1)}${pad2(date.getUTCDate())}T${pad2(date.getUTCHours())}${pad2(date.getUTCMinutes())}${pad2(date.getUTCSeconds())}Z`;
1885
+ }
1886
+ function pad(value) {
1887
+ return String(value).padStart(2, "0");
1888
+ }
1889
+ function dueProperty(due) {
1890
+ const text2 = String(due ?? "").trim();
1891
+ if (text2 === "") return null;
1892
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text2)) return `DUE;VALUE=DATE:${text2.replaceAll("-", "")}`;
1893
+ const parsed = new Date(text2);
1894
+ if (Number.isNaN(parsed.getTime())) return null;
1895
+ return `DUE:${utcStamp(parsed)}`;
1896
+ }
1897
+ function serializeTodos(tasks) {
1898
+ const lines = [
1899
+ "BEGIN:VCALENDAR",
1900
+ "VERSION:2.0",
1901
+ "PRODID:-//dsh-mailharbor//VTODO//EN",
1902
+ "CALSCALE:GREGORIAN"
1903
+ ];
1904
+ for (const task of tasks) {
1905
+ lines.push("BEGIN:VTODO");
1906
+ lines.push(`UID:${String(task.uid ?? task.id ?? "")}`);
1907
+ lines.push(`DTSTAMP:${utcStamp(/* @__PURE__ */ new Date())}`);
1908
+ lines.push(`SUMMARY:${escapeText(task.title ?? "(\u65E0\u6807\u9898)")}`);
1909
+ if (task.notes !== void 0 && task.notes !== null && String(task.notes) !== "") {
1910
+ lines.push(`DESCRIPTION:${escapeText(task.notes)}`);
1911
+ }
1912
+ const due = dueProperty(String(task.due ?? ""));
1913
+ if (due !== null) lines.push(due);
1914
+ lines.push(`STATUS:${String(task.status ?? "NEEDS-ACTION").toUpperCase()}`);
1915
+ if (Number(task.priority ?? 0) > 0)
1916
+ lines.push(`PRIORITY:${String(Math.max(0, Math.min(9, Number(task.priority))))}`);
1917
+ if (String(task.status ?? "").toUpperCase() === "COMPLETED") {
1918
+ const completed = task.completedAt === void 0 || task.completedAt === null ? /* @__PURE__ */ new Date() : new Date(task.completedAt);
1919
+ if (!Number.isNaN(completed.getTime())) lines.push(`COMPLETED:${utcStamp(completed)}`);
1920
+ }
1921
+ lines.push("END:VTODO");
1922
+ }
1923
+ lines.push("END:VCALENDAR");
1924
+ return `${lines.map(fold).join(CRLF)}${CRLF}`;
1925
+ }
1926
+ function unfold(text2) {
1927
+ return String(text2 ?? "").replace(/\r?\n[ \t]/g, "");
1928
+ }
1929
+ function decodeDue(value, params) {
1930
+ const text2 = String(value ?? "").trim();
1931
+ if (params.includes("VALUE=DATE") || /^\d{8}$/.test(text2)) {
1932
+ if (/^\d{8}$/.test(text2)) return `${text2.slice(0, 4)}-${text2.slice(4, 6)}-${text2.slice(6, 8)}`;
1933
+ return "";
1934
+ }
1935
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(text2);
1936
+ if (match !== null) {
1937
+ const date = new Date(
1938
+ Date.UTC(
1939
+ Number(match[1]),
1940
+ Number(match[2]) - 1,
1941
+ Number(match[3]),
1942
+ Number(match[4]),
1943
+ Number(match[5]),
1944
+ Number(match[6])
1945
+ )
1946
+ );
1947
+ return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
1948
+ }
1949
+ return "";
1950
+ }
1951
+ function parseTodos(ics) {
1952
+ const lines = unfold(ics).split(/\r?\n/);
1953
+ const todos = [];
1954
+ let current = null;
1955
+ for (const line2 of lines) {
1956
+ const trimmed = line2.trim();
1957
+ if (trimmed === "BEGIN:VTODO") {
1958
+ current = {};
1959
+ continue;
1960
+ }
1961
+ if (trimmed === "END:VTODO") {
1962
+ if (current !== null && String(current.uid ?? "") !== "") {
1963
+ todos.push({
1964
+ uid: String(current.uid),
1965
+ title: String(current.title ?? "(\u65E0\u6807\u9898)"),
1966
+ notes: String(current.notes ?? ""),
1967
+ due: String(current.due ?? ""),
1968
+ status: String(current.status ?? "NEEDS-ACTION"),
1969
+ priority: Number(current.priority ?? 0),
1970
+ completedAt: current.completedAt ?? null
1971
+ });
1972
+ }
1973
+ current = null;
1974
+ continue;
1975
+ }
1976
+ if (current === null) continue;
1977
+ const colon = trimmed.indexOf(":");
1978
+ if (colon < 0) continue;
1979
+ const left = trimmed.slice(0, colon);
1980
+ const value = trimmed.slice(colon + 1);
1981
+ const semicolon = left.indexOf(";");
1982
+ const name2 = (semicolon < 0 ? left : left.slice(0, semicolon)).toUpperCase();
1983
+ const params = semicolon < 0 ? "" : left.slice(semicolon + 1).toUpperCase();
1984
+ if (name2 === "UID") current.uid = unescapeText(value).trim();
1985
+ else if (name2 === "SUMMARY") current.title = unescapeText(value);
1986
+ else if (name2 === "DESCRIPTION") current.notes = unescapeText(value);
1987
+ else if (name2 === "STATUS") current.status = value.trim().toUpperCase();
1988
+ else if (name2 === "PRIORITY") current.priority = Number(value) || 0;
1989
+ else if (name2 === "DUE") current.due = decodeDue(value, params);
1990
+ else if (name2 === "COMPLETED") {
1991
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value.trim());
1992
+ if (match !== null) {
1993
+ current.completedAt = new Date(
1994
+ Date.UTC(
1995
+ Number(match[1]),
1996
+ Number(match[2]) - 1,
1997
+ Number(match[3]),
1998
+ Number(match[4]),
1999
+ Number(match[5]),
2000
+ Number(match[6])
2001
+ )
2002
+ ).toISOString();
2003
+ }
2004
+ }
2005
+ }
2006
+ return todos;
2007
+ }
2008
+
2009
+ // src/host/caldav.ts
2010
+ function decodeXml(value) {
2011
+ return String(value ?? "").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&amp;", "&");
2012
+ }
2013
+ function tagValue(xml, name2) {
2014
+ const pattern = new RegExp(`<(?:[\\w-]+:)?${name2}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${name2}>`, "i");
2015
+ const match = pattern.exec(String(xml ?? ""));
2016
+ return match === null ? "" : decodeXml(match[1] ?? "");
2017
+ }
2018
+ function responseBlocks(xml) {
2019
+ return [
2020
+ ...String(xml ?? "").matchAll(/<(?:[\w-]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?response>/gi)
2021
+ ].map((match) => match[1] ?? "");
2022
+ }
2023
+ function resolveHref(base, href) {
2024
+ const text2 = String(href ?? "").trim();
2025
+ if (text2 === "") return "";
2026
+ return new URL(text2, base).href;
2027
+ }
2028
+ function withSlash(url) {
2029
+ return url.endsWith("/") ? url : `${url}/`;
2030
+ }
2031
+ var PROPFIND_ROOT = `<?xml version="1.0" encoding="utf-8"?>
2032
+ <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
2033
+ <d:prop><d:current-user-principal/><c:calendar-home-set/></d:prop>
2034
+ </d:propfind>`;
2035
+ var PROPFIND_HOME = `<?xml version="1.0" encoding="utf-8"?>
2036
+ <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
2037
+ <d:prop><d:resourcetype/><d:displayname/><c:supported-calendar-component-set/></d:prop>
2038
+ </d:propfind>`;
2039
+ var REPORT_TODOS = `<?xml version="1.0" encoding="utf-8"?>
2040
+ <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
2041
+ <d:prop><d:getetag/><c:calendar-data/></d:prop>
2042
+ <c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VTODO"/></c:comp-filter></c:filter>
2043
+ </c:calendar-query>`;
2044
+ var CalDavClient = class {
2045
+ url;
2046
+ username;
2047
+ password;
2048
+ calendarHref;
2049
+ constructor(config, password) {
2050
+ this.url = withSlash(String(config.url ?? "").trim());
2051
+ this.username = String(config.username ?? "");
2052
+ this.password = String(password ?? "");
2053
+ this.calendarHref = String(config.calendarHref ?? "").trim();
2054
+ }
2055
+ headers(extra = {}) {
2056
+ const auth = Buffer.from(`${this.username}:${this.password}`, "utf8").toString("base64");
2057
+ return { Authorization: `Basic ${auth}`, ...extra };
2058
+ }
2059
+ async request(method, url, options = {}) {
2060
+ if (this.url === "") throw new Error("CalDAV \u5730\u5740\u672A\u914D\u7F6E");
2061
+ const controller = new AbortController();
2062
+ const timer = setTimeout(() => {
2063
+ controller.abort();
2064
+ }, 2e4);
2065
+ let response;
2066
+ try {
2067
+ response = await fetch(url, {
2068
+ method,
2069
+ headers: this.headers({
2070
+ ...options.depth === void 0 ? {} : { Depth: String(options.depth) },
2071
+ ...options.body === void 0 ? {} : { "Content-Type": "application/xml; charset=utf-8" },
2072
+ ...options.headers ?? {}
2073
+ }),
2074
+ body: options.body,
2075
+ signal: controller.signal,
2076
+ redirect: "follow"
2077
+ });
2078
+ } catch (error) {
2079
+ if (error instanceof Error && error.name === "AbortError") throw new Error("CalDAV \u8FDE\u63A5\u8D85\u65F6");
2080
+ throw new Error(`CalDAV \u8FDE\u63A5\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
2081
+ } finally {
2082
+ clearTimeout(timer);
2083
+ }
2084
+ const text2 = await response.text();
2085
+ if (response.status === 401 || response.status === 403)
2086
+ throw new Error("CalDAV \u8BA4\u8BC1\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7528\u6237\u540D\u548C\u5E94\u7528\u5BC6\u7801");
2087
+ if (response.status === 404) throw new Error("CalDAV \u5730\u5740\u6216\u65E5\u5386\u4E0D\u5B58\u5728");
2088
+ if (response.status === 412) throw new Error("CalDAV \u4EFB\u52A1\u5DF2\u88AB\u5176\u4ED6\u5BA2\u6237\u7AEF\u4FEE\u6539\uFF0C\u8BF7\u5148\u540C\u6B65");
2089
+ if (!response.ok && response.status !== 207) {
2090
+ throw new Error(`CalDAV \u8FD4\u56DE ${String(response.status)}${text2 === "" ? "" : `\uFF1A${text2.slice(0, 160)}`}`);
2091
+ }
2092
+ return { status: response.status, text: text2, headers: response.headers };
2093
+ }
2094
+ async discover() {
2095
+ if (this.calendarHref !== "") return this.calendarHref;
2096
+ const root = await this.request("PROPFIND", this.url, { body: PROPFIND_ROOT, depth: 0 });
2097
+ const principalBlock = tagValue(root.text, "current-user-principal");
2098
+ const principal = tagValue(principalBlock, "href") || (principalBlock.trim().startsWith("/") ? principalBlock.trim() : "");
2099
+ const homeBlock = tagValue(root.text, "calendar-home-set");
2100
+ let home = tagValue(homeBlock, "href") || (homeBlock.trim().startsWith("/") ? homeBlock.trim() : "");
2101
+ if (home === "") {
2102
+ if (principal === "") throw new Error("CalDAV \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE calendar-home-set");
2103
+ const principalUrl = resolveHref(this.url, principal);
2104
+ const principalResponse = await this.request("PROPFIND", principalUrl, {
2105
+ body: PROPFIND_ROOT,
2106
+ depth: 0
2107
+ });
2108
+ const principalHome = tagValue(principalResponse.text, "calendar-home-set");
2109
+ home = tagValue(principalHome, "href") || (principalHome.trim().startsWith("/") ? principalHome.trim() : "");
2110
+ if (home === "") throw new Error("CalDAV \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u65E5\u5386\u76EE\u5F55");
2111
+ }
2112
+ const homeUrl = withSlash(resolveHref(this.url, home));
2113
+ const listing = await this.request("PROPFIND", homeUrl, { body: PROPFIND_HOME, depth: 1 });
2114
+ const calendars = [];
2115
+ for (const block of responseBlocks(listing.text)) {
2116
+ const href = tagValue(block, "href");
2117
+ if (href === "") continue;
2118
+ if (!/calendar/i.test(tagValue(block, "resourcetype"))) continue;
2119
+ const supported = tagValue(block, "supported-calendar-component-set");
2120
+ if (supported !== "" && !/VTODO/i.test(supported)) continue;
2121
+ calendars.push({
2122
+ href: withSlash(resolveHref(homeUrl, href)),
2123
+ displayName: tagValue(block, "displayname") || href
2124
+ });
2125
+ }
2126
+ if (calendars.length === 0) throw new Error("\u6CA1\u6709\u627E\u5230\u652F\u6301 VTODO \u7684\u4EFB\u52A1\u65E5\u5386");
2127
+ this.calendarHref = calendars[0]?.href ?? "";
2128
+ return this.calendarHref;
2129
+ }
2130
+ async listCalendars() {
2131
+ if (this.calendarHref !== "") return [{ href: this.calendarHref, displayName: "" }];
2132
+ const root = await this.request("PROPFIND", this.url, { body: PROPFIND_ROOT, depth: 0 });
2133
+ const homeBlock = tagValue(root.text, "calendar-home-set");
2134
+ const home = tagValue(homeBlock, "href") || (homeBlock.trim().startsWith("/") ? homeBlock.trim() : "");
2135
+ if (home === "") throw new Error("CalDAV \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u65E5\u5386\u76EE\u5F55");
2136
+ const homeUrl = withSlash(resolveHref(this.url, home));
2137
+ const listing = await this.request("PROPFIND", homeUrl, { body: PROPFIND_HOME, depth: 1 });
2138
+ return responseBlocks(listing.text).filter((block) => /calendar/i.test(tagValue(block, "resourcetype"))).map((block) => ({
2139
+ href: withSlash(resolveHref(homeUrl, tagValue(block, "href"))),
2140
+ displayName: tagValue(block, "displayname")
2141
+ }));
2142
+ }
2143
+ async fetchTodos(collectionHref) {
2144
+ const collection = withSlash(resolveHref(this.url, collectionHref));
2145
+ const response = await this.request("REPORT", collection, { body: REPORT_TODOS, depth: 1 });
2146
+ const items = [];
2147
+ for (const block of responseBlocks(response.text)) {
2148
+ const href = tagValue(block, "href");
2149
+ if (href === "") continue;
2150
+ const etag = tagValue(block, "getetag") || null;
2151
+ const data = tagValue(block, "calendar-data");
2152
+ if (data === "") continue;
2153
+ for (const todo of parseTodos(data)) {
2154
+ if (todo.uid === "") continue;
2155
+ items.push({ href: resolveHref(collection, href), etag, todo });
2156
+ }
2157
+ }
2158
+ return items;
2159
+ }
2160
+ async putTodo(collectionHref, task) {
2161
+ const collection = withSlash(resolveHref(this.url, collectionHref));
2162
+ const href = task.href !== null && task.href !== "" ? task.href : new URL(`${encodeURIComponent(task.uid)}.ics`, collection).href;
2163
+ const headers = { "Content-Type": "text/calendar; charset=utf-8" };
2164
+ if (task.etag !== null && task.etag !== "") headers["If-Match"] = task.etag;
2165
+ else headers["If-None-Match"] = "*";
2166
+ const response = await this.request("PUT", href, { body: serializeTodos([task]), headers });
2167
+ return { href, etag: response.headers.get("etag") ?? task.etag, uid: task.uid };
2168
+ }
2169
+ async deleteTodo(href, etag) {
2170
+ const headers = {};
2171
+ if (etag !== null && etag !== "") headers["If-Match"] = etag;
2172
+ await this.request("DELETE", resolveHref(this.url, href), { headers });
2173
+ }
2174
+ /** Pull remote VTODOs, then push local dirty/tombstoned tasks. */
2175
+ async sync(store, accountId) {
2176
+ const collection = await this.discover();
2177
+ const remote = await this.fetchTodos(collection);
2178
+ let pulled = 0;
2179
+ for (const item of remote) {
2180
+ store.applyRemote({
2181
+ accountId,
2182
+ calendarHref: collection,
2183
+ href: item.href,
2184
+ etag: item.etag,
2185
+ todo: item.todo
2186
+ });
2187
+ pulled += 1;
2188
+ }
2189
+ const removed = store.pruneMissingRemote(
2190
+ accountId,
2191
+ collection,
2192
+ remote.map((item) => item.href)
2193
+ );
2194
+ let pushed = 0;
2195
+ let deleted = 0;
2196
+ for (const task of store.pending(accountId)) {
2197
+ if (task.deleted) {
2198
+ if (task.href !== null && task.href !== "") {
2199
+ await this.deleteTodo(task.href, task.etag);
2200
+ deleted += 1;
2201
+ }
2202
+ store.purge(task.id);
2203
+ continue;
2204
+ }
2205
+ const result = await this.putTodo(
2206
+ task.calendarHref !== null && task.calendarHref !== "" ? task.calendarHref : collection,
2207
+ task
2208
+ );
2209
+ store.markPushed(task.id, result);
2210
+ pushed += 1;
2211
+ }
2212
+ store.setCalendarState(accountId, {
2213
+ calendarHref: collection,
2214
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
2215
+ error: null
2216
+ });
2217
+ return { pulled, pushed, deleted, removed, calendarHref: collection, total: remote.length };
2218
+ }
2219
+ };
2220
+
2221
+ // src/host/projects.ts
2222
+ import { randomUUID as randomUUID2 } from "node:crypto";
2223
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
2224
+ import { join as join5 } from "node:path";
2225
+ var ProjectStore = class {
2226
+ file;
2227
+ cache = null;
2228
+ constructor(file) {
2229
+ this.file = file;
2230
+ }
2231
+ load() {
2232
+ if (this.cache !== null) return this.cache;
2233
+ let parsed = null;
2234
+ try {
2235
+ if (existsSync5(this.file)) parsed = JSON.parse(readFileSync4(this.file, "utf8"));
2236
+ } catch {
2237
+ parsed = null;
2238
+ }
2239
+ const projects = parsed !== null && typeof parsed === "object" && Array.isArray(parsed.projects) ? parsed.projects : [];
2240
+ this.cache = { version: 1, projects: projects.filter(isProject) };
2241
+ return this.cache;
2242
+ }
2243
+ flush() {
2244
+ const data = this.load();
2245
+ mkdirSync4(join5(this.file, ".."), { recursive: true });
2246
+ const temp = `${this.file}.${String(process.pid)}.tmp`;
2247
+ writeFileSync4(temp, `${JSON.stringify(data, null, 2)}
2248
+ `, { mode: 384 });
2249
+ renameSync3(temp, this.file);
2250
+ }
2251
+ list() {
2252
+ return [...this.load().projects].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
2253
+ }
2254
+ get(id) {
2255
+ return this.load().projects.find((project) => project.id === id);
2256
+ }
2257
+ findByMessage(accountId, key) {
2258
+ return this.load().projects.find(
2259
+ (project) => project.messages.some((message) => message.accountId === accountId && message.key === key)
2260
+ );
2261
+ }
2262
+ create(record) {
2263
+ const data = this.load();
2264
+ data.projects.push(record);
2265
+ this.flush();
2266
+ return record;
2267
+ }
2268
+ update(id, patch) {
2269
+ const project = this.get(id);
2270
+ if (project === void 0) return void 0;
2271
+ Object.assign(project, patch, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
2272
+ this.flush();
2273
+ return project;
2274
+ }
2275
+ addMessage(id, message) {
2276
+ const project = this.get(id);
2277
+ if (project === void 0) return void 0;
2278
+ const exists = project.messages.some(
2279
+ (item) => item.accountId === message.accountId && item.key === message.key
2280
+ );
2281
+ if (!exists) project.messages.push(message);
2282
+ if (!project.accountIds.includes(message.accountId)) project.accountIds.push(message.accountId);
2283
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2284
+ this.flush();
2285
+ return project;
2286
+ }
2287
+ removeMessage(id, accountId, key) {
2288
+ const project = this.get(id);
2289
+ if (project === void 0) return void 0;
2290
+ project.messages = project.messages.filter((item) => !(item.accountId === accountId && item.key === key));
2291
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2292
+ this.flush();
2293
+ return project;
2294
+ }
2295
+ addThread(id, thread) {
2296
+ const project = this.get(id);
2297
+ if (project === void 0) return void 0;
2298
+ if (!project.threads.some((item) => item.accountId === thread.accountId && item.key === thread.key)) {
2299
+ project.threads.push(thread);
2300
+ }
2301
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2302
+ this.flush();
2303
+ return project;
2304
+ }
2305
+ };
2306
+ function isProject(value) {
2307
+ return value !== null && typeof value === "object" && typeof value.id === "string" && typeof value.path === "string";
2308
+ }
2309
+ function slugify(name2) {
2310
+ const text2 = name2.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
2311
+ return text2 === "" ? "project" : text2.slice(0, 60);
2312
+ }
2313
+ async function createProject(options) {
2314
+ const { store, input } = options;
2315
+ const name2 = input.name.trim();
2316
+ if (name2 === "") throw new Error("\u9879\u76EE\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A");
2317
+ if (name2.length > 120) throw new Error("\u9879\u76EE\u540D\u79F0\u8FC7\u957F");
2318
+ const root = input.root.trim();
2319
+ if (root === "") throw new Error("\u9879\u76EE\u76EE\u5F55\u4E0D\u80FD\u4E3A\u7A7A");
2320
+ const warnings = [];
2321
+ const id = `prj_${randomUUID2().slice(0, 8)}`;
2322
+ const base = slugify(name2);
2323
+ let path = join5(root, base);
2324
+ let attempt = 2;
2325
+ while (existsSync5(path)) {
2326
+ path = join5(root, `${base}-${String(attempt)}`);
2327
+ attempt += 1;
2328
+ }
2329
+ mkdirSync4(path, { recursive: true });
2330
+ const created = (/* @__PURE__ */ new Date()).toISOString();
2331
+ const brief = renderBrief(name2, input);
2332
+ writeFileSync4(join5(path, "BRIEF.md"), brief, { mode: 384 });
2333
+ writeFileSync4(join5(path, "CONVERSATION.md"), renderConversation(input.conversation, created), {
2334
+ mode: 384
2335
+ });
2336
+ let workspaceId = "";
2337
+ if (options.registry !== void 0) {
2338
+ try {
2339
+ const workspace = await options.registry.create(path, name2);
2340
+ workspaceId = String(workspace?.id ?? "");
2341
+ } catch (error) {
2342
+ warnings.push(`\u5DE5\u4F5C\u7A7A\u95F4\u6CE8\u518C\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
2343
+ }
2344
+ } else {
2345
+ warnings.push("\u5F53\u524D DSH \u7EC4\u5408\u6CA1\u6709 workspaceRegistry\uFF0C\u9879\u76EE\u76EE\u5F55\u5DF2\u521B\u5EFA\u4F46\u6CA1\u6709\u6CE8\u518C\u5230\u4FA7\u680F");
2346
+ }
2347
+ let sessionId = "";
2348
+ if (options.sessions !== void 0) {
2349
+ try {
2350
+ sessionId = randomUUID2();
2351
+ const session = options.sessions.create(sessionId, { meta: { cwd: path } });
2352
+ session.append(
2353
+ "user/message",
2354
+ {
2355
+ role: "user",
2356
+ content: [{ type: "text", text: `\u6211\u521B\u5EFA\u4E86\u4E00\u4E2A\u90AE\u4EF6\u9879\u76EE\u300C${name2}\u300D\u3002
2357
+
2358
+ ${brief}` }],
2359
+ source: { kind: "user" }
2360
+ },
2361
+ { surfaceOp: "append" }
2362
+ );
2363
+ } catch (error) {
2364
+ warnings.push(`\u4F1A\u8BDD\u521B\u5EFA\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
2365
+ sessionId = "";
2366
+ }
2367
+ } else {
2368
+ warnings.push("\u5F53\u524D DSH \u7EC4\u5408\u6CA1\u6709 sessions \u670D\u52A1\uFF0C\u672A\u521B\u5EFA\u9879\u76EE\u4F1A\u8BDD");
2369
+ }
2370
+ const project = {
2371
+ id,
2372
+ name: name2,
2373
+ path,
2374
+ workspaceId,
2375
+ sessionId,
2376
+ accountIds: [input.accountId],
2377
+ messages: input.message === void 0 ? [] : [input.message],
2378
+ threads: input.thread === void 0 ? [] : [input.thread],
2379
+ createdAt: created,
2380
+ updatedAt: created
2381
+ };
2382
+ writeFileSync4(
2383
+ join5(path, "project.json"),
2384
+ `${JSON.stringify({ ...project, conversation: void 0 }, null, 2)}
2385
+ `,
2386
+ {
2387
+ mode: 384
2388
+ }
2389
+ );
2390
+ store.create(project);
2391
+ return { project, warnings };
2392
+ }
2393
+ function appendConversation(project, conversation) {
2394
+ const stamp3 = (/* @__PURE__ */ new Date()).toISOString();
2395
+ const file = join5(project.path, "CONVERSATION.md");
2396
+ const block = `
2397
+
2398
+ ## \u8FFD\u52A0\u8BB0\u5F55 \xB7 ${stamp3}
2399
+
2400
+ ${conversation.trim()}
2401
+ `;
2402
+ writeFileSync4(file, block, { flag: "a", mode: 384 });
2403
+ return file;
2404
+ }
2405
+ function renderBrief(name2, input) {
2406
+ const lines = [`# ${name2}`, "", `- \u521B\u5EFA\u65F6\u95F4\uFF1A${(/* @__PURE__ */ new Date()).toISOString()}`, `- \u6765\u6E90\u8D26\u53F7\uFF1A${input.accountId}`];
2407
+ if (input.message !== void 0) {
2408
+ lines.push(`- \u6765\u6E90\u90AE\u4EF6\uFF1A${input.message.subject}\uFF08${input.message.date}\uFF09`);
2409
+ }
2410
+ lines.push(
2411
+ "",
2412
+ "## \u80CC\u666F",
2413
+ "",
2414
+ "\u7531 MailHarbor \u4ECE\u90AE\u4EF6\u751F\u6210\u3002\u4EE5\u4E0B\u662F\u5F53\u65F6 AI \u5DE5\u4F5C\u53F0\u7684\u8BB0\u5F55\u3002",
2415
+ "",
2416
+ "## AI \u5DE5\u4F5C\u53F0\u8BB0\u5F55",
2417
+ "",
2418
+ input.conversation.trim(),
2419
+ ""
2420
+ );
2421
+ return lines.join("\n");
2422
+ }
2423
+ function renderConversation(conversation, created) {
2424
+ return `# AI \u5DE5\u4F5C\u53F0\u8BB0\u5F55
2425
+
2426
+ - \u8F6C\u79FB\u5230\u9879\u76EE\uFF1A${created}
2427
+
2428
+ ${conversation.trim()}
2429
+ `;
2430
+ }
2431
+
1797
2432
  // src/host/version.ts
1798
- var VERSION = "0.4.1";
2433
+ var VERSION = "0.5.0";
1799
2434
 
1800
2435
  // src/host/api.ts
1801
2436
  init_smtp();
@@ -1901,6 +2536,22 @@ function buildRouter(runtime) {
1901
2536
  const page = runtime.database(account).list({ mailbox: mailbox === "" ? "all" : mailbox, query, offset, limit });
1902
2537
  return { ...page, items: page.items.map((item) => localSummary(runtime, account, item)) };
1903
2538
  });
2539
+ router.get("/messages/lookup", ({ url }) => {
2540
+ const account = accountFromPayload(runtime, { accountId: url.searchParams.get("accountId") ?? void 0 });
2541
+ const key = url.searchParams.get("key") ?? "";
2542
+ if (key.startsWith("local:")) {
2543
+ const id = Number(key.slice(6));
2544
+ const detail = runtime.database(account).get(id);
2545
+ if (detail === void 0) throw new HttpError(404, "\u90AE\u4EF6\u4E0D\u5B58\u5728");
2546
+ return { id: String(id), mailbox: detail.mailbox, subject: detail.subject };
2547
+ }
2548
+ if (key.startsWith("mid:")) {
2549
+ const found = runtime.database(account).findByMessageId(key.slice(4));
2550
+ if (found === void 0) throw new HttpError(404, "\u672C\u5730\u5907\u4EFD\u91CC\u627E\u4E0D\u5230\u8FD9\u5C01\u90AE\u4EF6");
2551
+ return { id: String(found.id), mailbox: found.mailbox, subject: "" };
2552
+ }
2553
+ throw new HttpError(400, "\u65E0\u6CD5\u5B9A\u4F4D\u8FD9\u5C01\u90AE\u4EF6");
2554
+ });
1904
2555
  router.get("/messages/:id", async ({ url, params }) => {
1905
2556
  const { account } = accountFrom(runtime, url);
1906
2557
  const source = sourceOf(url);
@@ -2160,7 +2811,7 @@ function buildRouter(runtime) {
2160
2811
  output = await ai.ask(detail, instruction, earlier);
2161
2812
  }
2162
2813
  const card = {
2163
- id: randomUUID2(),
2814
+ id: randomUUID3(),
2164
2815
  kind,
2165
2816
  instruction,
2166
2817
  output,
@@ -2274,6 +2925,192 @@ function buildRouter(runtime) {
2274
2925
  throw aiHttpError(error);
2275
2926
  }
2276
2927
  });
2928
+ router.get("/tasks", ({ url }) => {
2929
+ const account = accountFromPayload(runtime, { accountId: url.searchParams.get("accountId") ?? void 0 });
2930
+ const items = runtime.tasks().list({
2931
+ accountId: account.id,
2932
+ status: url.searchParams.get("status") ?? "",
2933
+ projectId: url.searchParams.get("projectId") ?? "",
2934
+ query: url.searchParams.get("query") ?? ""
2935
+ });
2936
+ return { items: items.map(taskView), calendar: runtime.tasks().calendarState(account.id) };
2937
+ });
2938
+ router.post("/tasks", async ({ body }) => {
2939
+ const payload = await body();
2940
+ const account = accountFromPayload(runtime, payload);
2941
+ const task = runtime.tasks().create({
2942
+ accountId: account.id,
2943
+ title: String(payload.title ?? ""),
2944
+ notes: typeof payload.notes === "string" ? payload.notes : "",
2945
+ due: typeof payload.due === "string" ? payload.due : "",
2946
+ status: typeof payload.status === "string" ? payload.status : "NEEDS-ACTION",
2947
+ priority: Number(payload.priority ?? 0),
2948
+ owner: typeof payload.owner === "string" ? payload.owner : "",
2949
+ projectId: typeof payload.projectId === "string" ? payload.projectId : null,
2950
+ messageKey: typeof payload.messageKey === "string" ? payload.messageKey : "",
2951
+ messageSubject: typeof payload.messageSubject === "string" ? payload.messageSubject : ""
2952
+ });
2953
+ return { task: taskView(task) };
2954
+ });
2955
+ router.patch("/tasks/:id", async ({ params, body }) => {
2956
+ const payload = await body();
2957
+ const task = runtime.tasks().update(String(params.id ?? ""), {
2958
+ ...typeof payload.title === "string" ? { title: payload.title } : {},
2959
+ ...typeof payload.notes === "string" ? { notes: payload.notes } : {},
2960
+ ...typeof payload.due === "string" ? { due: payload.due } : {},
2961
+ ...typeof payload.status === "string" ? { status: payload.status } : {},
2962
+ ...typeof payload.priority === "number" ? { priority: payload.priority } : {},
2963
+ ...typeof payload.owner === "string" ? { owner: payload.owner } : {},
2964
+ ...payload.projectId === null || typeof payload.projectId === "string" ? { projectId: payload.projectId } : {}
2965
+ });
2966
+ return { task: taskView(task) };
2967
+ });
2968
+ router.delete("/tasks/:id", ({ params }) => {
2969
+ const removed = runtime.tasks().remove(String(params.id ?? ""));
2970
+ if (!removed) throw new HttpError(404, "\u4EFB\u52A1\u4E0D\u5B58\u5728");
2971
+ return { ok: true };
2972
+ });
2973
+ router.post("/tasks/extract", async ({ body }) => {
2974
+ const payload = await body();
2975
+ const account = accountFromPayload(runtime, payload);
2976
+ const detail = await loadDetail(
2977
+ runtime,
2978
+ account,
2979
+ sourceOfPayload(payload),
2980
+ String(payload.id ?? ""),
2981
+ mailboxOfPayload(payload)
2982
+ );
2983
+ try {
2984
+ const suggestions = await runtime.ai().extractTasks(detail);
2985
+ return { suggestions };
2986
+ } catch (error) {
2987
+ throw aiHttpError(error);
2988
+ }
2989
+ });
2990
+ router.get("/tasks/caldav", ({ url }) => {
2991
+ const account = accountFromPayload(runtime, { accountId: url.searchParams.get("accountId") ?? void 0 });
2992
+ return {
2993
+ caldav: account.caldav,
2994
+ calendar: runtime.tasks().calendarState(account.id),
2995
+ hasPassword: runtime.credentials.readSecret(`${account.keychainService}:caldav`, account.email) !== void 0
2996
+ };
2997
+ });
2998
+ router.put("/tasks/caldav", async ({ body }) => {
2999
+ const payload = await body();
3000
+ const account = accountFromPayload(runtime, payload);
3001
+ const url = String(payload.url ?? "").trim();
3002
+ const username = String(payload.username ?? "").trim();
3003
+ const calendarHref = String(payload.calendarHref ?? "").trim();
3004
+ const caldav = url === "" ? null : { url, username, ...calendarHref === "" ? {} : { calendarHref } };
3005
+ const accounts = runtime.current.accounts.map(
3006
+ (item) => item.id === account.id ? { ...item, caldav } : item
3007
+ );
3008
+ saveAccounts(accounts, runtime.current.activeAccountId);
3009
+ if (typeof payload.password === "string" && payload.password !== "") {
3010
+ runtime.credentials.writeSecret(`${account.keychainService}:caldav`, account.email, payload.password);
3011
+ }
3012
+ runtime.refresh();
3013
+ const updated = runtime.current.accounts.find((item) => item.id === account.id) ?? account;
3014
+ return {
3015
+ caldav: updated.caldav,
3016
+ title: "",
3017
+ hasPassword: runtime.credentials.readSecret(`${updated.keychainService}:caldav`, updated.email) !== void 0
3018
+ };
3019
+ });
3020
+ router.post("/tasks/sync", async ({ body }) => {
3021
+ const payload = await body();
3022
+ const account = accountFromPayload(runtime, payload);
3023
+ const caldav = account.caldav;
3024
+ if (caldav === null) throw new HttpError(400, "\u8BF7\u5148\u914D\u7F6E CalDAV \u5730\u5740\u548C\u8D26\u53F7");
3025
+ const password = runtime.credentials.readSecret(`${account.keychainService}:caldav`, account.email);
3026
+ if (password === void 0 || password === "") throw new HttpError(400, "\u8BF7\u5148\u4FDD\u5B58 CalDAV \u5E94\u7528\u5BC6\u7801");
3027
+ try {
3028
+ const client = new CalDavClient(caldav, password);
3029
+ const result = await runtime.tasks().sync(account.id, client);
3030
+ if (result.calendarHref !== "" && result.calendarHref !== (caldav.calendarHref ?? "")) {
3031
+ const accounts = runtime.current.accounts.map(
3032
+ (item) => item.id === account.id ? { ...item, caldav: { ...caldav, calendarHref: result.calendarHref } } : item
3033
+ );
3034
+ saveAccounts(accounts, runtime.current.activeAccountId);
3035
+ runtime.refresh();
3036
+ }
3037
+ return { result, calendar: runtime.tasks().calendarState(account.id) };
3038
+ } catch (error) {
3039
+ throw new HttpError(400, error instanceof Error ? error.message : String(error));
3040
+ }
3041
+ });
3042
+ router.get("/projects", () => ({ items: runtime.projects().list().map(projectView) }));
3043
+ router.get("/projects/for", ({ url }) => {
3044
+ const account = accountFromPayload(runtime, { accountId: url.searchParams.get("accountId") ?? void 0 });
3045
+ const key = url.searchParams.get("key") ?? "";
3046
+ return {
3047
+ project: key === "" ? null : (() => {
3048
+ const found = runtime.projects().findByMessage(account.id, key);
3049
+ return found === void 0 ? null : projectView(found);
3050
+ })()
3051
+ };
3052
+ });
3053
+ router.post("/projects", async ({ body }) => {
3054
+ const payload = await body();
3055
+ const account = accountFromPayload(runtime, payload);
3056
+ const name2 = String(payload.name ?? "").trim();
3057
+ const root = String(payload.root ?? "").trim() || runtime.current.projects.root;
3058
+ const conversation = typeof payload.conversation === "string" ? payload.conversation : "";
3059
+ const message = asObject(payload.message);
3060
+ const key = typeof message.key === "string" ? message.key : "";
3061
+ const result = await createProject({
3062
+ store: runtime.projects(),
3063
+ input: {
3064
+ name: name2,
3065
+ root,
3066
+ conversation,
3067
+ accountId: account.id,
3068
+ ...key === "" ? {} : {
3069
+ message: {
3070
+ accountId: account.id,
3071
+ key,
3072
+ subject: String(message.subject ?? ""),
3073
+ date: String(message.date ?? "")
3074
+ }
3075
+ },
3076
+ ...key === "" ? {} : { thread: { accountId: account.id, key } }
3077
+ },
3078
+ registry: runtime.workspaceRegistry,
3079
+ sessions: runtime.sessions
3080
+ });
3081
+ return { project: projectView(result.project), warnings: result.warnings };
3082
+ });
3083
+ router.post("/projects/:id/messages", async ({ params, body }) => {
3084
+ const payload = await body();
3085
+ const account = accountFromPayload(runtime, payload);
3086
+ const project = runtime.projects().addMessage(String(params.id ?? ""), {
3087
+ accountId: account.id,
3088
+ key: String(payload.key ?? ""),
3089
+ subject: String(payload.subject ?? ""),
3090
+ date: String(payload.date ?? "")
3091
+ });
3092
+ if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3093
+ return { project: projectView(project) };
3094
+ });
3095
+ router.delete("/projects/:id/messages", ({ params, url }) => {
3096
+ const account = accountFromPayload(runtime, { accountId: url.searchParams.get("accountId") ?? void 0 });
3097
+ const key = url.searchParams.get("key") ?? "";
3098
+ const project = runtime.projects().removeMessage(String(params.id ?? ""), account.id, key);
3099
+ if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3100
+ return { project: projectView(project) };
3101
+ });
3102
+ router.post("/projects/:id/transfer", async ({ params, body }) => {
3103
+ const payload = await body();
3104
+ const account = accountFromPayload(runtime, payload);
3105
+ const project = runtime.projects().get(String(params.id ?? ""));
3106
+ if (project === void 0) throw new HttpError(404, "\u9879\u76EE\u4E0D\u5B58\u5728");
3107
+ const conversation = typeof payload.conversation === "string" ? payload.conversation : "";
3108
+ if (conversation.trim() === "") throw new HttpError(400, "\u6CA1\u6709\u53EF\u8F6C\u79FB\u7684 AI \u8BB0\u5F55");
3109
+ appendConversation(project, conversation);
3110
+ const key = typeof payload.key === "string" ? payload.key : "";
3111
+ const updated = key === "" ? runtime.projects().update(project.id, {}) : runtime.projects().addThread(project.id, { accountId: account.id, key });
3112
+ return { project: projectView(updated ?? project) };
3113
+ });
2277
3114
  router.get("/settings", () => settingsView(runtime.current));
2278
3115
  router.patch("/settings", async ({ body }) => {
2279
3116
  const payload = await body();
@@ -2282,6 +3119,11 @@ function buildRouter(runtime) {
2282
3119
  if (payload.ai !== void 0) patch.ai = asObject(payload.ai);
2283
3120
  if (payload.read !== void 0) patch.read = asObject(payload.read);
2284
3121
  if (payload.render !== void 0) patch.render = asObject(payload.render);
3122
+ if (payload.projects !== void 0) {
3123
+ const root = String(asObject(payload.projects).root ?? "").trim();
3124
+ if (root === "") throw new HttpError(400, "\u9879\u76EE\u76EE\u5F55\u4E0D\u80FD\u4E3A\u7A7A");
3125
+ patch.projects = { root: resolveUserPath(root, "\u9879\u76EE\u76EE\u5F55") };
3126
+ }
2285
3127
  saveSettings(patch);
2286
3128
  runtime.refresh();
2287
3129
  return { settings: settingsView(runtime.current) };
@@ -2423,7 +3265,41 @@ function settingsView(config) {
2423
3265
  styleSampleCount: config.ai.styleSampleCount
2424
3266
  },
2425
3267
  read: { markOnOpen: config.read.markOnOpen, pollSeconds: config.read.pollSeconds },
2426
- render: { listLimit: config.render.listLimit }
3268
+ render: { listLimit: config.render.listLimit },
3269
+ projects: { root: config.projects.root }
3270
+ };
3271
+ }
3272
+ function taskView(task) {
3273
+ return {
3274
+ id: task.id,
3275
+ accountId: task.accountId,
3276
+ projectId: task.projectId,
3277
+ title: task.title,
3278
+ notes: task.notes,
3279
+ due: task.due,
3280
+ status: task.status,
3281
+ priority: task.priority,
3282
+ owner: task.owner,
3283
+ messageKey: task.messageKey,
3284
+ messageSubject: task.messageSubject,
3285
+ dirty: task.dirty,
3286
+ createdAt: task.createdAt,
3287
+ updatedAt: task.updatedAt,
3288
+ completedAt: task.completedAt
3289
+ };
3290
+ }
3291
+ function projectView(project) {
3292
+ return {
3293
+ id: project.id,
3294
+ name: project.name,
3295
+ path: project.path,
3296
+ workspaceId: project.workspaceId,
3297
+ sessionId: project.sessionId,
3298
+ accountIds: [...project.accountIds],
3299
+ messages: project.messages.map((message) => ({ ...message })),
3300
+ threads: project.threads.map((thread) => ({ ...thread })),
3301
+ createdAt: project.createdAt,
3302
+ updatedAt: project.updatedAt
2427
3303
  };
2428
3304
  }
2429
3305
  function localSummary(runtime, account, row) {
@@ -2577,7 +3453,7 @@ ${signature}`;
2577
3453
 
2578
3454
  // src/host/runtime.ts
2579
3455
  import { statSync } from "node:fs";
2580
- import { join as join5 } from "node:path";
3456
+ import { join as join7 } from "node:path";
2581
3457
 
2582
3458
  // src/host/secrets.ts
2583
3459
  import { spawnSync } from "node:child_process";
@@ -2746,6 +3622,16 @@ var CredentialManager = class {
2746
3622
  this.session.delete(account.id);
2747
3623
  this.secrets.delete(account.keychainService, account.email);
2748
3624
  }
3625
+ /** Read an arbitrary secret (for example a CalDAV app password). */
3626
+ readSecret(service, account) {
3627
+ return this.secrets.read(service, account);
3628
+ }
3629
+ writeSecret(service, account, secret) {
3630
+ return this.secrets.write(service, account, secret);
3631
+ }
3632
+ deleteSecret(service, account) {
3633
+ return this.secrets.delete(service, account);
3634
+ }
2749
3635
  status(account) {
2750
3636
  const resolved = this.resolve(account);
2751
3637
  return {
@@ -2757,7 +3643,7 @@ var CredentialManager = class {
2757
3643
  };
2758
3644
 
2759
3645
  // src/host/ai-store.ts
2760
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
3646
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
2761
3647
  import { dirname as dirname2 } from "node:path";
2762
3648
  var MAX_THREADS = 50;
2763
3649
  var MAX_CARDS_PER_THREAD = 50;
@@ -2772,7 +3658,7 @@ var AiThreadStore = class {
2772
3658
  if (this.cache !== null) return this.cache;
2773
3659
  let parsed = null;
2774
3660
  try {
2775
- if (existsSync5(this.file)) parsed = JSON.parse(readFileSync4(this.file, "utf8"));
3661
+ if (existsSync6(this.file)) parsed = JSON.parse(readFileSync5(this.file, "utf8"));
2776
3662
  } catch {
2777
3663
  parsed = null;
2778
3664
  }
@@ -2785,10 +3671,10 @@ var AiThreadStore = class {
2785
3671
  prune(data);
2786
3672
  const text2 = `${JSON.stringify(data, null, 2)}
2787
3673
  `;
2788
- mkdirSync4(dirname2(this.file), { recursive: true });
3674
+ mkdirSync5(dirname2(this.file), { recursive: true });
2789
3675
  const temp = `${this.file}.${String(process.pid)}.tmp`;
2790
- writeFileSync4(temp, text2, { mode: 384 });
2791
- renameSync3(temp, this.file);
3676
+ writeFileSync5(temp, text2, { mode: 384 });
3677
+ renameSync4(temp, this.file);
2792
3678
  }
2793
3679
  get(key) {
2794
3680
  const thread = this.load().threads[key];
@@ -2796,7 +3682,7 @@ var AiThreadStore = class {
2796
3682
  }
2797
3683
  appendCard(input) {
2798
3684
  const data = this.load();
2799
- const now = (/* @__PURE__ */ new Date()).toISOString();
3685
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
2800
3686
  const existing = data.threads[input.key];
2801
3687
  const thread = existing ?? {
2802
3688
  key: input.key,
@@ -2804,13 +3690,13 @@ var AiThreadStore = class {
2804
3690
  subject: input.subject,
2805
3691
  messageId: input.messageId ?? "",
2806
3692
  cards: [],
2807
- updatedAt: now
3693
+ updatedAt: now2
2808
3694
  };
2809
3695
  thread.accountId = input.accountId;
2810
3696
  thread.subject = input.subject;
2811
3697
  if (input.messageId !== void 0 && input.messageId !== "") thread.messageId = input.messageId;
2812
3698
  thread.cards.push(input.card);
2813
- thread.updatedAt = now;
3699
+ thread.updatedAt = now2;
2814
3700
  data.threads[input.key] = thread;
2815
3701
  this.flush();
2816
3702
  return { ...thread, cards: [...thread.cards] };
@@ -2896,10 +3782,330 @@ function prune(data) {
2896
3782
  }
2897
3783
  }
2898
3784
 
3785
+ // src/host/tasks.ts
3786
+ import { randomUUID as randomUUID4 } from "node:crypto";
3787
+ import { mkdirSync as mkdirSync6 } from "node:fs";
3788
+ import { dirname as dirname3 } from "node:path";
3789
+ import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
3790
+ var STATUSES = /* @__PURE__ */ new Set(["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"]);
3791
+ function now() {
3792
+ return (/* @__PURE__ */ new Date()).toISOString();
3793
+ }
3794
+ function rowToTask(row) {
3795
+ if (row === void 0) return void 0;
3796
+ return {
3797
+ id: String(row.id),
3798
+ accountId: String(row.account_id),
3799
+ projectId: row.project_id === null || row.project_id === void 0 ? null : String(row.project_id),
3800
+ calendarHref: row.calendar_href === null || row.calendar_href === void 0 ? null : String(row.calendar_href),
3801
+ href: row.href === null || row.href === void 0 ? null : String(row.href),
3802
+ etag: row.etag === null || row.etag === void 0 ? null : String(row.etag),
3803
+ uid: String(row.uid),
3804
+ title: String(row.title),
3805
+ notes: String(row.notes ?? ""),
3806
+ due: String(row.due ?? ""),
3807
+ status: String(row.status ?? "NEEDS-ACTION"),
3808
+ priority: Number(row.priority ?? 0),
3809
+ owner: String(row.owner ?? ""),
3810
+ messageKey: String(row.message_key ?? ""),
3811
+ messageSubject: String(row.message_subject ?? ""),
3812
+ completedAt: row.completed_at === null || row.completed_at === void 0 ? null : String(row.completed_at),
3813
+ dirty: Number(row.dirty) === 1,
3814
+ deleted: Number(row.deleted) === 1,
3815
+ createdAt: String(row.created_at ?? ""),
3816
+ updatedAt: String(row.updated_at ?? "")
3817
+ };
3818
+ }
3819
+ function statusOf(value) {
3820
+ const text2 = String(value ?? "NEEDS-ACTION").toUpperCase();
3821
+ return STATUSES.has(text2) ? text2 : "NEEDS-ACTION";
3822
+ }
3823
+ var TaskStore = class {
3824
+ file;
3825
+ db = null;
3826
+ constructor(file) {
3827
+ this.file = file;
3828
+ }
3829
+ close() {
3830
+ if (this.db !== null) {
3831
+ this.db.close();
3832
+ this.db = null;
3833
+ }
3834
+ }
3835
+ open() {
3836
+ if (this.db !== null) return this.db;
3837
+ mkdirSync6(dirname3(this.file), { recursive: true });
3838
+ const db = new DatabaseSync3(this.file);
3839
+ db.exec("PRAGMA journal_mode=WAL");
3840
+ db.exec("PRAGMA busy_timeout=10000");
3841
+ db.exec(`
3842
+ CREATE TABLE IF NOT EXISTS tasks (
3843
+ id TEXT PRIMARY KEY,
3844
+ account_id TEXT NOT NULL,
3845
+ project_id TEXT,
3846
+ calendar_href TEXT,
3847
+ href TEXT,
3848
+ etag TEXT,
3849
+ uid TEXT NOT NULL,
3850
+ title TEXT NOT NULL,
3851
+ notes TEXT,
3852
+ due TEXT,
3853
+ status TEXT NOT NULL DEFAULT 'NEEDS-ACTION',
3854
+ priority INTEGER NOT NULL DEFAULT 0,
3855
+ owner TEXT,
3856
+ message_key TEXT,
3857
+ message_subject TEXT,
3858
+ completed_at TEXT,
3859
+ dirty INTEGER NOT NULL DEFAULT 1,
3860
+ deleted INTEGER NOT NULL DEFAULT 0,
3861
+ created_at TEXT NOT NULL,
3862
+ updated_at TEXT NOT NULL
3863
+ );
3864
+ CREATE INDEX IF NOT EXISTS tasks_account_due ON tasks (account_id, deleted, status, due);
3865
+ CREATE INDEX IF NOT EXISTS tasks_project ON tasks (project_id);
3866
+ CREATE TABLE IF NOT EXISTS calendar_state (
3867
+ account_id TEXT PRIMARY KEY,
3868
+ calendar_href TEXT,
3869
+ display_name TEXT,
3870
+ ctag TEXT,
3871
+ last_sync TEXT,
3872
+ error TEXT
3873
+ );
3874
+ `);
3875
+ this.db = db;
3876
+ return db;
3877
+ }
3878
+ list(options) {
3879
+ const db = this.open();
3880
+ const where = ["account_id = ?", "deleted = 0"];
3881
+ const params = [options.accountId];
3882
+ if (options.status !== void 0 && options.status !== "") {
3883
+ where.push("status = ?");
3884
+ params.push(statusOf(options.status));
3885
+ }
3886
+ if (options.projectId !== void 0 && options.projectId !== "") {
3887
+ where.push("project_id = ?");
3888
+ params.push(options.projectId);
3889
+ }
3890
+ const text2 = String(options.query ?? "").trim();
3891
+ if (text2 !== "") {
3892
+ where.push("(title LIKE ? OR notes LIKE ? OR owner LIKE ?)");
3893
+ const like = `%${text2}%`;
3894
+ params.push(like, like, like);
3895
+ }
3896
+ const rows = db.prepare(
3897
+ `SELECT * FROM tasks WHERE ${where.join(" AND ")}
3898
+ ORDER BY CASE WHEN completed_at IS NOT NULL THEN 1 ELSE 0 END,
3899
+ CASE WHEN due IS NULL OR due = '' THEN 1 ELSE 0 END, due ASC, created_at DESC
3900
+ LIMIT ?`
3901
+ ).all(...params, Math.max(1, Math.min(1e3, options.limit ?? 500)));
3902
+ return rows.map((row) => rowToTask(row)).filter((task) => task !== void 0);
3903
+ }
3904
+ get(id) {
3905
+ return rowToTask(
3906
+ this.open().prepare("SELECT * FROM tasks WHERE id = ?").get(id)
3907
+ );
3908
+ }
3909
+ create(input) {
3910
+ const title = String(input.title ?? "").trim();
3911
+ if (title === "") throw new Error("\u4EFB\u52A1\u6807\u9898\u4E0D\u80FD\u4E3A\u7A7A");
3912
+ if (title.length > 300) throw new Error("\u4EFB\u52A1\u6807\u9898\u8FC7\u957F");
3913
+ const status = statusOf(input.status);
3914
+ const id = `task_${randomUUID4()}`;
3915
+ const stamp3 = now();
3916
+ this.open().prepare(
3917
+ `INSERT INTO tasks (id, account_id, project_id, uid, title, notes, due, status, priority, owner,
3918
+ message_key, message_subject, completed_at, dirty, deleted, created_at, updated_at)
3919
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`
3920
+ ).run(
3921
+ id,
3922
+ input.accountId,
3923
+ input.projectId ?? null,
3924
+ `${randomUUID4()}@dsh-mailharbor`,
3925
+ title,
3926
+ String(input.notes ?? ""),
3927
+ String(input.due ?? ""),
3928
+ status,
3929
+ Math.max(0, Math.min(9, Number(input.priority ?? 0))),
3930
+ String(input.owner ?? ""),
3931
+ String(input.messageKey ?? ""),
3932
+ String(input.messageSubject ?? ""),
3933
+ status === "COMPLETED" ? stamp3 : null,
3934
+ stamp3,
3935
+ stamp3
3936
+ );
3937
+ const task = this.get(id);
3938
+ if (task === void 0) throw new Error("\u4EFB\u52A1\u521B\u5EFA\u5931\u8D25");
3939
+ return task;
3940
+ }
3941
+ update(id, fields) {
3942
+ const current = this.get(id);
3943
+ if (current === void 0) throw new Error("\u4EFB\u52A1\u4E0D\u5B58\u5728");
3944
+ const next = {
3945
+ title: fields.title === void 0 ? current.title : String(fields.title).trim(),
3946
+ notes: fields.notes === void 0 ? current.notes : String(fields.notes ?? ""),
3947
+ due: fields.due === void 0 ? current.due : String(fields.due ?? ""),
3948
+ status: fields.status === void 0 ? current.status : statusOf(fields.status),
3949
+ priority: fields.priority === void 0 ? current.priority : Number(fields.priority),
3950
+ owner: fields.owner === void 0 ? current.owner : String(fields.owner ?? ""),
3951
+ projectId: fields.projectId === void 0 ? current.projectId : fields.projectId,
3952
+ messageKey: fields.messageKey === void 0 ? current.messageKey : String(fields.messageKey),
3953
+ messageSubject: fields.messageSubject === void 0 ? current.messageSubject : String(fields.messageSubject)
3954
+ };
3955
+ if (next.title === "") throw new Error("\u4EFB\u52A1\u6807\u9898\u4E0D\u80FD\u4E3A\u7A7A");
3956
+ const stamp3 = now();
3957
+ const completedAt = next.status === "COMPLETED" ? current.completedAt ?? stamp3 : null;
3958
+ this.open().prepare(
3959
+ `UPDATE tasks SET title = ?, notes = ?, due = ?, status = ?, priority = ?, owner = ?, project_id = ?,
3960
+ message_key = ?, message_subject = ?, completed_at = ?, dirty = 1, updated_at = ? WHERE id = ?`
3961
+ ).run(
3962
+ next.title,
3963
+ next.notes,
3964
+ next.due,
3965
+ next.status,
3966
+ Math.max(0, Math.min(9, Number(next.priority) || 0)),
3967
+ next.owner,
3968
+ next.projectId,
3969
+ next.messageKey,
3970
+ next.messageSubject,
3971
+ completedAt,
3972
+ stamp3,
3973
+ id
3974
+ );
3975
+ const task = this.get(id);
3976
+ if (task === void 0) throw new Error("\u4EFB\u52A1\u66F4\u65B0\u5931\u8D25");
3977
+ return task;
3978
+ }
3979
+ remove(id) {
3980
+ const current = this.get(id);
3981
+ if (current === void 0) return false;
3982
+ if (current.href !== null && current.href !== "") {
3983
+ this.open().prepare("UPDATE tasks SET deleted = 1, dirty = 1, updated_at = ? WHERE id = ?").run(now(), id);
3984
+ } else {
3985
+ this.open().prepare("DELETE FROM tasks WHERE id = ?").run(id);
3986
+ }
3987
+ return true;
3988
+ }
3989
+ /** Apply a VTODO fetched from CalDAV; local pending edits win over remote. */
3990
+ applyRemote(input) {
3991
+ const db = this.open();
3992
+ const existing = db.prepare("SELECT * FROM tasks WHERE account_id = ? AND href = ?").get(input.accountId, input.href) ?? db.prepare("SELECT * FROM tasks WHERE account_id = ? AND uid = ?").get(input.accountId, input.todo.uid);
3993
+ const status = statusOf(input.todo.status);
3994
+ const completedAt = status === "COMPLETED" ? input.todo.completedAt ?? now() : null;
3995
+ if (existing !== void 0) {
3996
+ if (Number(existing.dirty) === 1) return rowToTask(existing);
3997
+ db.prepare(
3998
+ `UPDATE tasks SET calendar_href = ?, href = ?, etag = ?, title = ?, notes = ?, due = ?, status = ?, priority = ?,
3999
+ completed_at = ?, dirty = 0, deleted = 0, updated_at = ? WHERE id = ?`
4000
+ ).run(
4001
+ input.calendarHref,
4002
+ input.href,
4003
+ input.etag,
4004
+ String(input.todo.title || existing.title),
4005
+ String(input.todo.notes ?? ""),
4006
+ String(input.todo.due ?? ""),
4007
+ status,
4008
+ Math.max(0, Math.min(9, Number(input.todo.priority) || 0)),
4009
+ completedAt,
4010
+ now(),
4011
+ String(existing.id)
4012
+ );
4013
+ return this.get(String(existing.id));
4014
+ }
4015
+ const id = `task_${randomUUID4()}`;
4016
+ const stamp3 = now();
4017
+ db.prepare(
4018
+ `INSERT INTO tasks (id, account_id, calendar_href, href, etag, uid, title, notes, due, status, priority,
4019
+ completed_at, dirty, deleted, created_at, updated_at)
4020
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`
4021
+ ).run(
4022
+ id,
4023
+ input.accountId,
4024
+ input.calendarHref,
4025
+ input.href,
4026
+ input.etag,
4027
+ input.todo.uid || `${randomUUID4()}@dsh-mailharbor`,
4028
+ String(input.todo.title || "(\u65E0\u6807\u9898)"),
4029
+ String(input.todo.notes ?? ""),
4030
+ String(input.todo.due ?? ""),
4031
+ status,
4032
+ Math.max(0, Math.min(9, Number(input.todo.priority) || 0)),
4033
+ completedAt,
4034
+ stamp3,
4035
+ stamp3
4036
+ );
4037
+ return this.get(id);
4038
+ }
4039
+ pending(accountId) {
4040
+ return this.open().prepare("SELECT * FROM tasks WHERE account_id = ? AND dirty = 1").all(accountId).map((row) => rowToTask(row)).filter((task) => task !== void 0);
4041
+ }
4042
+ markPushed(id, result) {
4043
+ this.open().prepare(
4044
+ "UPDATE tasks SET href = ?, etag = ?, uid = ?, dirty = 0, deleted = 0, updated_at = ? WHERE id = ?"
4045
+ ).run(result.href, result.etag, result.uid, now(), id);
4046
+ return this.get(id);
4047
+ }
4048
+ purge(id) {
4049
+ this.open().prepare("DELETE FROM tasks WHERE id = ?").run(id);
4050
+ }
4051
+ /** Remove local copies whose remote VTODO disappeared (not dirty, not tombstoned). */
4052
+ pruneMissingRemote(accountId, calendarHref, hrefs) {
4053
+ const db = this.open();
4054
+ const present = new Set(hrefs);
4055
+ const rows = db.prepare(
4056
+ `SELECT id, href FROM tasks
4057
+ WHERE account_id = ? AND dirty = 0 AND deleted = 0 AND href IS NOT NULL
4058
+ AND (calendar_href = ? OR calendar_href IS NULL OR calendar_href = '')`
4059
+ ).all(accountId, calendarHref);
4060
+ let removed = 0;
4061
+ for (const row of rows) {
4062
+ if (present.has(String(row.href))) continue;
4063
+ db.prepare("DELETE FROM tasks WHERE id = ?").run(String(row.id));
4064
+ removed += 1;
4065
+ }
4066
+ return removed;
4067
+ }
4068
+ calendarState(accountId) {
4069
+ const row = this.open().prepare("SELECT * FROM calendar_state WHERE account_id = ?").get(accountId);
4070
+ if (row === void 0) return null;
4071
+ return {
4072
+ calendarHref: String(row.calendar_href ?? ""),
4073
+ lastSync: String(row.last_sync ?? ""),
4074
+ error: String(row.error ?? "")
4075
+ };
4076
+ }
4077
+ setCalendarState(accountId, state) {
4078
+ this.open().prepare(
4079
+ `INSERT INTO calendar_state (account_id, calendar_href, display_name, ctag, last_sync, error)
4080
+ VALUES (?, ?, NULL, NULL, ?, ?)
4081
+ ON CONFLICT(account_id) DO UPDATE SET calendar_href = excluded.calendar_href,
4082
+ last_sync = excluded.last_sync, error = excluded.error`
4083
+ ).run(accountId, state.calendarHref ?? null, state.lastSync ?? null, state.error ?? null);
4084
+ }
4085
+ /** Pull remote tasks, push local edits, and record the sync result. */
4086
+ async sync(accountId, client) {
4087
+ try {
4088
+ const result = await client.sync(this, accountId);
4089
+ this.setCalendarState(accountId, {
4090
+ calendarHref: result.calendarHref,
4091
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
4092
+ error: null
4093
+ });
4094
+ return result;
4095
+ } catch (error) {
4096
+ this.setCalendarState(accountId, {
4097
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
4098
+ error: error instanceof Error ? error.message : String(error)
4099
+ });
4100
+ throw error;
4101
+ }
4102
+ }
4103
+ };
4104
+
2899
4105
  // src/host/drafts.ts
2900
- import { randomUUID as randomUUID3 } from "node:crypto";
2901
- import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
2902
- import { dirname as dirname3, join as join4 } from "node:path";
4106
+ import { randomUUID as randomUUID5 } from "node:crypto";
4107
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
4108
+ import { dirname as dirname4, join as join6 } from "node:path";
2903
4109
  function text(value) {
2904
4110
  return value === void 0 || value === null ? "" : String(value);
2905
4111
  }
@@ -2910,7 +4116,7 @@ function isDraft(value) {
2910
4116
  return value !== null && typeof value === "object" && typeof value.id === "string";
2911
4117
  }
2912
4118
  function draftPath(root) {
2913
- return join4(root, "drafts.json");
4119
+ return join6(root, "drafts.json");
2914
4120
  }
2915
4121
  var DraftStore = class {
2916
4122
  file;
@@ -2923,18 +4129,18 @@ var DraftStore = class {
2923
4129
  }
2924
4130
  load() {
2925
4131
  try {
2926
- const parsed = JSON.parse(readFileSync5(this.file, "utf8"));
4132
+ const parsed = JSON.parse(readFileSync6(this.file, "utf8"));
2927
4133
  this.items = Array.isArray(parsed) ? parsed.filter(isDraft) : [];
2928
4134
  } catch {
2929
4135
  this.items = [];
2930
4136
  }
2931
4137
  }
2932
4138
  flush() {
2933
- mkdirSync5(dirname3(this.file), { recursive: true });
4139
+ mkdirSync7(dirname4(this.file), { recursive: true });
2934
4140
  const temp = `${this.file}.${String(process.pid)}.${String(Date.now())}.tmp`;
2935
- writeFileSync5(temp, `${JSON.stringify(this.items, null, 2)}
4141
+ writeFileSync6(temp, `${JSON.stringify(this.items, null, 2)}
2936
4142
  `, { encoding: "utf8", mode: 384 });
2937
- renameSync4(temp, this.file);
4143
+ renameSync5(temp, this.file);
2938
4144
  }
2939
4145
  list() {
2940
4146
  return [...this.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).map((draft) => ({ ...draft }));
@@ -2944,7 +4150,7 @@ var DraftStore = class {
2944
4150
  return found === void 0 ? void 0 : { ...found };
2945
4151
  }
2946
4152
  save(input) {
2947
- const now = (/* @__PURE__ */ new Date()).toISOString();
4153
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
2948
4154
  const existing = input.id === void 0 ? void 0 : this.items.find((draft2) => draft2.id === input.id);
2949
4155
  const fields = {
2950
4156
  to: text(input.to),
@@ -2961,15 +4167,15 @@ var DraftStore = class {
2961
4167
  references: text(input.references)
2962
4168
  };
2963
4169
  if (existing !== void 0) {
2964
- Object.assign(existing, fields, { updatedAt: now });
4170
+ Object.assign(existing, fields, { updatedAt: now2 });
2965
4171
  this.flush();
2966
4172
  return { ...existing };
2967
4173
  }
2968
4174
  const draft = {
2969
- id: randomUUID3(),
4175
+ id: randomUUID5(),
2970
4176
  accountId: this.accountId,
2971
4177
  ...fields,
2972
- updatedAt: now
4178
+ updatedAt: now2
2973
4179
  };
2974
4180
  this.items.push(draft);
2975
4181
  this.flush();
@@ -2991,8 +4197,8 @@ var DraftStore = class {
2991
4197
  init_imap();
2992
4198
 
2993
4199
  // src/host/read-state.ts
2994
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, renameSync as renameSync5, writeFileSync as writeFileSync6 } from "node:fs";
2995
- import { dirname as dirname4 } from "node:path";
4200
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync7, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
4201
+ import { dirname as dirname5 } from "node:path";
2996
4202
  var ReadStateStore = class {
2997
4203
  file;
2998
4204
  keys = null;
@@ -3002,7 +4208,7 @@ var ReadStateStore = class {
3002
4208
  load() {
3003
4209
  if (this.keys !== null) return this.keys;
3004
4210
  try {
3005
- const parsed = JSON.parse(readFileSync6(this.file, "utf8"));
4211
+ const parsed = JSON.parse(readFileSync7(this.file, "utf8"));
3006
4212
  const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.unread) ? parsed.unread : [];
3007
4213
  this.keys = new Set(list.filter((key) => typeof key === "string" && key !== ""));
3008
4214
  } catch {
@@ -3011,14 +4217,14 @@ var ReadStateStore = class {
3011
4217
  return this.keys;
3012
4218
  }
3013
4219
  flush() {
3014
- mkdirSync6(dirname4(this.file), { recursive: true });
4220
+ mkdirSync8(dirname5(this.file), { recursive: true });
3015
4221
  const temp = `${this.file}.${String(process.pid)}.tmp`;
3016
- writeFileSync6(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
4222
+ writeFileSync7(temp, `${JSON.stringify({ unread: [...this.load()] }, null, 2)}
3017
4223
  `, {
3018
4224
  encoding: "utf8",
3019
4225
  mode: 384
3020
4226
  });
3021
- renameSync5(temp, this.file);
4227
+ renameSync6(temp, this.file);
3022
4228
  }
3023
4229
  isUnread(key) {
3024
4230
  return key !== "" && this.load().has(key);
@@ -3077,12 +4283,30 @@ var MailHarborRuntime = class {
3077
4283
  aiStores = /* @__PURE__ */ new Map();
3078
4284
  backups = /* @__PURE__ */ new Map();
3079
4285
  llmProvider;
4286
+ workspaceRegistry;
4287
+ sessions;
3080
4288
  aiService = null;
4289
+ projectStore = null;
4290
+ taskStore = null;
3081
4291
  disposed = false;
3082
4292
  constructor(options = {}) {
3083
4293
  this.config = loadConfig();
3084
4294
  this.credentials = options.credentials ?? new CredentialManager();
3085
4295
  this.llmProvider = options.llm ?? (() => void 0);
4296
+ this.workspaceRegistry = options.workspaceRegistry;
4297
+ this.sessions = options.sessions;
4298
+ }
4299
+ projects() {
4300
+ if (this.projectStore === null) {
4301
+ this.projectStore = new ProjectStore(join7(this.config.dataDir, "projects.json"));
4302
+ }
4303
+ return this.projectStore;
4304
+ }
4305
+ tasks() {
4306
+ if (this.taskStore === null) {
4307
+ this.taskStore = new TaskStore(join7(this.config.dataDir, "state", "state.db"));
4308
+ }
4309
+ return this.taskStore;
3086
4310
  }
3087
4311
  /** The Host model service, when the composition provides one. */
3088
4312
  llm() {
@@ -3208,7 +4432,7 @@ var MailHarborRuntime = class {
3208
4432
  return created;
3209
4433
  }
3210
4434
  aiThreads(account) {
3211
- const file = join5(this.paths(account).root, "ai-threads.json");
4435
+ const file = join7(this.paths(account).root, "ai-threads.json");
3212
4436
  const existing = this.aiStores.get(account.id);
3213
4437
  if (existing !== void 0 && existing.signature === file) return existing.value;
3214
4438
  const created = new AiThreadStore(file);
@@ -3216,7 +4440,7 @@ var MailHarborRuntime = class {
3216
4440
  return created;
3217
4441
  }
3218
4442
  readState(account) {
3219
- const file = join5(this.paths(account).root, "read-state.json");
4443
+ const file = join7(this.paths(account).root, "read-state.json");
3220
4444
  const existing = this.readStores.get(account.id);
3221
4445
  if (existing !== void 0 && existing.signature === file) return existing.value;
3222
4446
  const created = new ReadStateStore(file);
@@ -3295,6 +4519,7 @@ var MailHarborRuntime = class {
3295
4519
  this.providers.clear();
3296
4520
  for (const db of this.databases.values()) db.value.close();
3297
4521
  this.databases.clear();
4522
+ this.taskStore?.close();
3298
4523
  }
3299
4524
  };
3300
4525
 
@@ -3677,8 +4902,22 @@ ${page.items.map(
3677
4902
  var name = "mailharbor";
3678
4903
  var inject = ["webServer", "tools"];
3679
4904
  var ROUTE = "/mail-plugin";
4905
+ function optionalService(ctx, name2) {
4906
+ try {
4907
+ const direct = ctx[name2];
4908
+ if (direct !== void 0 && direct !== null) return direct;
4909
+ } catch {
4910
+ }
4911
+ try {
4912
+ return ctx.get?.(name2) ?? void 0;
4913
+ } catch {
4914
+ return void 0;
4915
+ }
4916
+ }
3680
4917
  function apply(ctx) {
3681
4918
  const runtime = new MailHarborRuntime({
4919
+ workspaceRegistry: optionalService(ctx, "workspaceRegistry"),
4920
+ sessions: optionalService(ctx, "sessions"),
3682
4921
  llm: () => {
3683
4922
  if (typeof ctx.get !== "function") return void 0;
3684
4923
  try {