@perrylink/dsh-ticktick 0.1.2
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/CHANGELOG.md +24 -0
- package/LICENSE +201 -0
- package/README.es.md +41 -0
- package/README.hi.md +33 -0
- package/README.md +111 -0
- package/README.pt.md +41 -0
- package/README.zh.md +111 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.js +6233 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1349 -0
- package/lib/typert.host.js +26 -0
- package/lib/types/client/TicktickAction.d.ts +29 -0
- package/lib/types/client/TicktickAction.d.ts.map +1 -0
- package/lib/types/client/TicktickSettingsCard.d.ts +27 -0
- package/lib/types/client/TicktickSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +34 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/dates.d.ts +26 -0
- package/lib/types/client/dates.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +61 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/order.d.ts +25 -0
- package/lib/types/client/order.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +260 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +13 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/domain.d.ts +70 -0
- package/lib/types/domain.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mcp.d.ts +94 -0
- package/lib/types/mcp.d.ts.map +1 -0
- package/lib/types/service.d.ts +160 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/tools.d.ts +18 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +216 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +561 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/lib/wire-C-vDxxnC.js +5288 -0
- package/package.json +187 -0
- package/probes/lib.mjs +65 -0
- package/probes/probe-bootstrap.mjs +10 -0
- package/probes/probe-crud.mjs +15 -0
- package/probes/probe-due.mjs +28 -0
- package/probes/probe-queries.mjs +18 -0
- package/probes/probe-reorder.mjs +24 -0
- package/src/client/TicktickAction.tsx +356 -0
- package/src/client/TicktickSettingsCard.tsx +182 -0
- package/src/client/api.ts +56 -0
- package/src/client/dates.ts +61 -0
- package/src/client/index.ts +119 -0
- package/src/client/locales.ts +113 -0
- package/src/client/order.ts +37 -0
- package/src/client/remote.ts +76 -0
- package/src/client/styles.ts +48 -0
- package/src/config.ts +136 -0
- package/src/domain.ts +224 -0
- package/src/index.ts +139 -0
- package/src/mcp.ts +213 -0
- package/src/service.ts +403 -0
- package/src/tools.ts +336 -0
- package/src/typert.host.ts +25 -0
- package/src/wire.ts +401 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
import { n as TICKTICK_SETTINGS_NS } from "./wire-C-vDxxnC.js";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
7
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
+
//#region src/config.ts
|
|
9
|
+
/**
|
|
10
|
+
* Plugin configuration and its explicit resolve step. `resolveConfig`
|
|
11
|
+
* re-judges every default and bound so programmatic construction that
|
|
12
|
+
* bypasses Schemastery normalization still fails loud instead of running
|
|
13
|
+
* with hidden defaults (the explicit-resolve contract).
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-ticktick/config
|
|
16
|
+
*/
|
|
17
|
+
/** Default TickTick MCP endpoint (CN region). */
|
|
18
|
+
const DEFAULT_MCP_URL = "https://mcp.dida365.com";
|
|
19
|
+
/** Default per-tools/call deadline in milliseconds. */
|
|
20
|
+
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 3e4;
|
|
21
|
+
/** Ceiling for one tool call: the endpoint may block on a slow server round. */
|
|
22
|
+
const MAX_TOOL_CALL_TIMEOUT_MS = 3e5;
|
|
23
|
+
/** Schemastery schema for loader-validated configuration. */
|
|
24
|
+
const Config = z.object({
|
|
25
|
+
tokenFile: z.string().default(""),
|
|
26
|
+
mcpUrl: z.string().default(DEFAULT_MCP_URL),
|
|
27
|
+
toolCallTimeoutMs: z.number().min(1e3).max(MAX_TOOL_CALL_TIMEOUT_MS).default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
28
|
+
protectedTaskIds: z.array(z.string()).default([]),
|
|
29
|
+
tools: z.object({
|
|
30
|
+
projects: z.string().default(""),
|
|
31
|
+
tasks: z.string().default(""),
|
|
32
|
+
create: z.string().default(""),
|
|
33
|
+
complete: z.string().default(""),
|
|
34
|
+
remove: z.string().default(""),
|
|
35
|
+
update: z.string().default(""),
|
|
36
|
+
move: z.string().default(""),
|
|
37
|
+
completed: z.string().default(""),
|
|
38
|
+
search: z.string().default(""),
|
|
39
|
+
getTask: z.string().default(""),
|
|
40
|
+
batchAdd: z.string().default("")
|
|
41
|
+
}).default({
|
|
42
|
+
projects: "",
|
|
43
|
+
tasks: "",
|
|
44
|
+
create: "",
|
|
45
|
+
complete: "",
|
|
46
|
+
remove: "",
|
|
47
|
+
update: "",
|
|
48
|
+
move: "",
|
|
49
|
+
completed: "",
|
|
50
|
+
search: "",
|
|
51
|
+
getTask: "",
|
|
52
|
+
batchAdd: ""
|
|
53
|
+
})
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Resolve raw config to the runtime policy, re-validating defaults and bounds.
|
|
57
|
+
* @param config - raw loader config; `undefined` for a bare row.
|
|
58
|
+
* @returns the frozen resolved config.
|
|
59
|
+
*/
|
|
60
|
+
function resolveConfig(config) {
|
|
61
|
+
const tokenFile = config?.tokenFile ?? "";
|
|
62
|
+
if (typeof tokenFile !== "string") throw new TypeError("dsh-ticktick: config.tokenFile must be a string");
|
|
63
|
+
const mcpUrl = config?.mcpUrl ?? "https://mcp.dida365.com";
|
|
64
|
+
if (typeof mcpUrl !== "string" || mcpUrl === "") throw new Error("dsh-ticktick: config.mcpUrl must be a non-empty string");
|
|
65
|
+
const toolCallTimeoutMs = config?.toolCallTimeoutMs ?? 3e4;
|
|
66
|
+
if (!Number.isFinite(toolCallTimeoutMs) || toolCallTimeoutMs < 1e3 || toolCallTimeoutMs > 3e5) throw new Error(`dsh-ticktick: config.toolCallTimeoutMs must be a finite number between 1000 and ${MAX_TOOL_CALL_TIMEOUT_MS}`);
|
|
67
|
+
const protectedTaskIds = (config?.protectedTaskIds ?? []).filter((id) => typeof id === "string" && id !== "");
|
|
68
|
+
const rawTools = config?.tools;
|
|
69
|
+
const pin = (key) => {
|
|
70
|
+
const value = rawTools?.[key];
|
|
71
|
+
return typeof value === "string" ? value : "";
|
|
72
|
+
};
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
tokenFile,
|
|
75
|
+
mcpUrl,
|
|
76
|
+
toolCallTimeoutMs,
|
|
77
|
+
protectedTaskIds: Object.freeze(protectedTaskIds),
|
|
78
|
+
tools: Object.freeze({
|
|
79
|
+
projects: pin("projects"),
|
|
80
|
+
tasks: pin("tasks"),
|
|
81
|
+
create: pin("create"),
|
|
82
|
+
complete: pin("complete"),
|
|
83
|
+
remove: pin("remove"),
|
|
84
|
+
update: pin("update"),
|
|
85
|
+
move: pin("move"),
|
|
86
|
+
completed: pin("completed"),
|
|
87
|
+
search: pin("search"),
|
|
88
|
+
getTask: pin("getTask"),
|
|
89
|
+
batchAdd: pin("batchAdd")
|
|
90
|
+
})
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/domain.ts
|
|
95
|
+
const PROJECTS_PATTERN = /(list|get)[^a-z0-9]*(project|projects)|(project|projects)[^a-z0-9]*(list|get)/i;
|
|
96
|
+
const TASKS_PATTERN = /(undone|open)[^a-z0-9]*(task|tasks)|project[^a-z0-9]*(with|undone)[^a-z0-9]*(task|tasks)/i;
|
|
97
|
+
const CREATE_PATTERN = /^(create|add|insert|new)[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*(create|add|insert|new)/i;
|
|
98
|
+
const COMPLETE_PATTERN = /^(complete|finish|done|close|check)[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*(complete|finish|done|close|check)/i;
|
|
99
|
+
const REMOVE_PATTERN = /^(delete|remove)[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*(delete|remove)/i;
|
|
100
|
+
const UPDATE_PATTERN = /^update[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*update/i;
|
|
101
|
+
const MOVE_PATTERN = /^move[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*move/i;
|
|
102
|
+
const COMPLETED_PATTERN = /completed[^a-z0-9]*(task|tasks)|completed_by/i;
|
|
103
|
+
const SEARCH_PATTERN = /^search(_task)?$/i;
|
|
104
|
+
const GET_TASK_PATTERN = /get[^a-z0-9]*task[^a-z0-9]*by[^a-z0-9]*id|^fetch$/i;
|
|
105
|
+
const BATCH_ADD_PATTERN = /batch[^a-z0-9]*add[^a-z0-9]*(task|tasks)/i;
|
|
106
|
+
/**
|
|
107
|
+
* Pick the raw tool names for the eleven operations, honoring pins.
|
|
108
|
+
* A pin that is not advertised falls back to pattern discovery, then to
|
|
109
|
+
* the first advertised tool, then to the pin itself (an empty resolver
|
|
110
|
+
* surfaces as a failed call, never a silent no-op).
|
|
111
|
+
* @param tools - the advertised tool list.
|
|
112
|
+
* @param pins - config-level name pins.
|
|
113
|
+
* @returns the resolved names.
|
|
114
|
+
*/
|
|
115
|
+
function resolveTools(tools, pins) {
|
|
116
|
+
const pick = (pattern, pin) => {
|
|
117
|
+
if (pin !== "" && tools.some((tool) => tool.name === pin)) return pin;
|
|
118
|
+
return tools.find((tool) => pattern.test(tool.name))?.name ?? (pin !== "" ? pin : tools[0]?.name) ?? "";
|
|
119
|
+
};
|
|
120
|
+
const searchPin = pins.search;
|
|
121
|
+
const exactSearch = tools.find((tool) => tool.name === "search");
|
|
122
|
+
const search = searchPin !== "" && tools.some((tool) => tool.name === searchPin) ? searchPin : exactSearch?.name ?? pick(SEARCH_PATTERN, searchPin);
|
|
123
|
+
return {
|
|
124
|
+
projects: pick(PROJECTS_PATTERN, pins.projects),
|
|
125
|
+
tasks: pick(TASKS_PATTERN, pins.tasks),
|
|
126
|
+
create: pick(CREATE_PATTERN, pins.create),
|
|
127
|
+
complete: pick(COMPLETE_PATTERN, pins.complete),
|
|
128
|
+
remove: pick(REMOVE_PATTERN, pins.remove),
|
|
129
|
+
update: pick(UPDATE_PATTERN, pins.update),
|
|
130
|
+
move: pick(MOVE_PATTERN, pins.move),
|
|
131
|
+
completed: pick(COMPLETED_PATTERN, pins.completed),
|
|
132
|
+
search,
|
|
133
|
+
getTask: pick(GET_TASK_PATTERN, pins.getTask),
|
|
134
|
+
batchAdd: pick(BATCH_ADD_PATTERN, pins.batchAdd)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const VALUE_KEY = /title|content|text|subject|name|desc/i;
|
|
138
|
+
const ID_KEY = /(^|_)(id|uuid)$|taskId|task_id/i;
|
|
139
|
+
const PROJECT_KEY = /projectId|project_id/i;
|
|
140
|
+
/** Parse the project entries out of a `list_projects` result (object or array payloads). */
|
|
141
|
+
function normalizeProjects(result) {
|
|
142
|
+
const projects = [];
|
|
143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
144
|
+
const accept = (record) => {
|
|
145
|
+
const id = record.id;
|
|
146
|
+
const name = record.name;
|
|
147
|
+
if (typeof id === "string" && typeof name === "string" && !seen.has(id)) {
|
|
148
|
+
seen.add(id);
|
|
149
|
+
projects.push({
|
|
150
|
+
id,
|
|
151
|
+
name
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
for (const block of result.content ?? []) {
|
|
156
|
+
if (block.type !== "text" || block.text === void 0) continue;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(block.text);
|
|
159
|
+
if (Array.isArray(parsed)) {
|
|
160
|
+
for (const entry of parsed) if (entry !== null && typeof entry === "object") accept(entry);
|
|
161
|
+
} else if (parsed !== null && typeof parsed === "object") accept(parsed);
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
return projects;
|
|
165
|
+
}
|
|
166
|
+
/** Walk a value for arrays whose elements look like task objects. */
|
|
167
|
+
function findTaskArrays(value, depth) {
|
|
168
|
+
if (depth > 8 || value === null || typeof value !== "object") return [];
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
const elements = value.filter((item) => item !== null && typeof item === "object" && !Array.isArray(item));
|
|
171
|
+
if (elements.length > 0 && elements.every((item) => {
|
|
172
|
+
return Object.keys(item).some((key) => VALUE_KEY.test(key));
|
|
173
|
+
})) return [value];
|
|
174
|
+
return value.flatMap((item) => findTaskArrays(item, depth + 1));
|
|
175
|
+
}
|
|
176
|
+
return Object.values(value).flatMap((item) => findTaskArrays(item, depth + 1));
|
|
177
|
+
}
|
|
178
|
+
/** Read the task id, title, done flag, project, due date, and sort order from one task-like record. */
|
|
179
|
+
function readTask(record) {
|
|
180
|
+
const keys = Object.keys(record);
|
|
181
|
+
const titleKey = keys.find((key) => VALUE_KEY.test(key) && typeof record[key] === "string");
|
|
182
|
+
const idKey = keys.find((key) => ID_KEY.test(key));
|
|
183
|
+
if (titleKey === void 0 || idKey === void 0) return null;
|
|
184
|
+
const idValue = record[idKey];
|
|
185
|
+
if (typeof idValue !== "string" && typeof idValue !== "number") return null;
|
|
186
|
+
const projectKey = keys.find((key) => PROJECT_KEY.test(key));
|
|
187
|
+
const projectValue = projectKey === void 0 ? void 0 : record[projectKey];
|
|
188
|
+
const dueKey = keys.find((key) => /dueDate|due_date|deadline/i.test(key));
|
|
189
|
+
const dueValue = dueKey === void 0 ? void 0 : record[dueKey];
|
|
190
|
+
const sortKey = keys.find((key) => /sortOrder|sort_order/i.test(key));
|
|
191
|
+
const sortValue = sortKey === void 0 ? void 0 : record[sortKey];
|
|
192
|
+
const status = record.status;
|
|
193
|
+
const done = status === 2 || status === "2" || status === "completed" || record.completed === true || record.done === true || record.finished === true;
|
|
194
|
+
return {
|
|
195
|
+
id: String(idValue),
|
|
196
|
+
title: record[titleKey],
|
|
197
|
+
done,
|
|
198
|
+
...typeof projectValue === "string" ? { projectId: projectValue } : {},
|
|
199
|
+
...typeof dueValue === "string" && dueValue !== "" ? { dueDate: dueValue } : {},
|
|
200
|
+
...typeof sortValue === "number" ? { sortOrder: sortValue } : {}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Normalize a tools/call result into the stable task view. */
|
|
204
|
+
function normalizeTasks(result) {
|
|
205
|
+
const sources = [result.structuredContent];
|
|
206
|
+
for (const block of result.content ?? []) if (block.type === "text" && block.text !== void 0) try {
|
|
207
|
+
sources.push(JSON.parse(block.text));
|
|
208
|
+
} catch {
|
|
209
|
+
sources.push(block.text);
|
|
210
|
+
}
|
|
211
|
+
const seen = /* @__PURE__ */ new Set();
|
|
212
|
+
const tasks = [];
|
|
213
|
+
for (const source of sources) {
|
|
214
|
+
if (source !== null && typeof source === "object" && !Array.isArray(source)) {
|
|
215
|
+
const record = source;
|
|
216
|
+
if (Object.keys(record).some((key) => /status|projectId|project_id|dueDate|due_date|sortOrder|sort_order/i.test(key))) {
|
|
217
|
+
const direct = readTask(record);
|
|
218
|
+
if (direct !== null && !seen.has(direct.id)) {
|
|
219
|
+
seen.add(direct.id);
|
|
220
|
+
tasks.push(direct);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const array of findTaskArrays(source, 0)) for (const item of array) {
|
|
225
|
+
const task = readTask(item);
|
|
226
|
+
if (task !== null && !seen.has(task.id)) {
|
|
227
|
+
seen.add(task.id);
|
|
228
|
+
tasks.push(task);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return tasks;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Whether a project id names the virtual inbox. Measured wire fact: inbox
|
|
236
|
+
* ids carry the literal 'inbox' (either exactly or inside an internal id),
|
|
237
|
+
* while regular projects are hex ids.
|
|
238
|
+
* @param projectId - the project id.
|
|
239
|
+
* @returns true for inbox ids.
|
|
240
|
+
*/
|
|
241
|
+
function isInboxProject(projectId) {
|
|
242
|
+
return projectId === "inbox" || projectId.includes("inbox");
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/mcp.ts
|
|
246
|
+
/**
|
|
247
|
+
* Parse one MCP response body: the last SSE `data:` payload, or plain JSON.
|
|
248
|
+
* @param body - raw response text.
|
|
249
|
+
* @returns the parsed message, or `undefined` for an empty body.
|
|
250
|
+
*/
|
|
251
|
+
function parseMcpMessage(body) {
|
|
252
|
+
if (body.trim() === "") return void 0;
|
|
253
|
+
const dataLines = body.split(/\r?\n/).filter((line) => line.startsWith("data:"));
|
|
254
|
+
if (dataLines.length > 0) {
|
|
255
|
+
const last = dataLines[dataLines.length - 1].slice(5).trim();
|
|
256
|
+
return JSON.parse(last);
|
|
257
|
+
}
|
|
258
|
+
return JSON.parse(body);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Streamable-HTTP MCP client with Bearer auth. Requests are simple POSTs;
|
|
262
|
+
* notifications tolerate empty responses. A 429 response is retried once
|
|
263
|
+
* after the server's Retry-After delay (bounded to one minute).
|
|
264
|
+
*/
|
|
265
|
+
var McpStreamableClient = class {
|
|
266
|
+
url;
|
|
267
|
+
token;
|
|
268
|
+
timeoutMs;
|
|
269
|
+
fetchFn;
|
|
270
|
+
sessionId;
|
|
271
|
+
protocolVersion;
|
|
272
|
+
nextId = 1;
|
|
273
|
+
/**
|
|
274
|
+
* @param url - MCP server URL (e.g. https://mcp.dida365.com).
|
|
275
|
+
* @param token - Bearer token (the TickTick API 口令).
|
|
276
|
+
* @param timeoutMs - per-request deadline in milliseconds.
|
|
277
|
+
* @param fetchFn - fetch implementation (defaults to global fetch).
|
|
278
|
+
*/
|
|
279
|
+
constructor(url, token, timeoutMs, fetchFn = fetch) {
|
|
280
|
+
this.url = url;
|
|
281
|
+
this.token = token;
|
|
282
|
+
this.timeoutMs = timeoutMs;
|
|
283
|
+
this.fetchFn = fetchFn;
|
|
284
|
+
}
|
|
285
|
+
/** Perform the initialize handshake and send the initialized notification. */
|
|
286
|
+
async initialize() {
|
|
287
|
+
const response = await this.send({
|
|
288
|
+
jsonrpc: "2.0",
|
|
289
|
+
id: this.nextId++,
|
|
290
|
+
method: "initialize",
|
|
291
|
+
params: {
|
|
292
|
+
protocolVersion: "2025-03-26",
|
|
293
|
+
capabilities: {},
|
|
294
|
+
clientInfo: {
|
|
295
|
+
name: "dsh-ticktick",
|
|
296
|
+
version: "0.1.0"
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}, false);
|
|
300
|
+
const body = await response.text();
|
|
301
|
+
if (response.status !== 200) throw new Error(`ticktick MCP initialize: HTTP ${response.status}: ${body.slice(0, 200)}`);
|
|
302
|
+
this.sessionId = response.headers.get("mcp-session-id") ?? void 0;
|
|
303
|
+
const message = parseMcpMessage(body);
|
|
304
|
+
this.protocolVersion = message?.result?.protocolVersion;
|
|
305
|
+
if (this.protocolVersion === void 0) throw new Error(`ticktick MCP initialize: unexpected response: ${body.slice(0, 200)}`);
|
|
306
|
+
await this.post({
|
|
307
|
+
jsonrpc: "2.0",
|
|
308
|
+
method: "notifications/initialized"
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/** Fetch the server's tool list. */
|
|
312
|
+
async listTools() {
|
|
313
|
+
return (await this.post({
|
|
314
|
+
jsonrpc: "2.0",
|
|
315
|
+
id: this.nextId++,
|
|
316
|
+
method: "tools/list",
|
|
317
|
+
params: {}
|
|
318
|
+
}))?.result?.tools ?? [];
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Invoke one tool. A tool-level failure (the MCP result flag `isError`)
|
|
322
|
+
* throws with the server's message — TickTick reports validation failures
|
|
323
|
+
* that way, not as JSON-RPC errors.
|
|
324
|
+
* @param name - the raw MCP tool name.
|
|
325
|
+
* @param args - the tool arguments object.
|
|
326
|
+
* @returns the settled result (text blocks or structured content).
|
|
327
|
+
*/
|
|
328
|
+
async callTool(name, args) {
|
|
329
|
+
const message = await this.post({
|
|
330
|
+
jsonrpc: "2.0",
|
|
331
|
+
id: this.nextId++,
|
|
332
|
+
method: "tools/call",
|
|
333
|
+
params: {
|
|
334
|
+
name,
|
|
335
|
+
arguments: args
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
if (message?.error !== void 0) throw new Error(`ticktick MCP tools/call ${name}: ${JSON.stringify(message.error)}`);
|
|
339
|
+
if (message?.result?.isError === true) {
|
|
340
|
+
const text = message.result.content?.filter((block) => block.type === "text" && block.text !== void 0).map((block) => block.text).join("\n");
|
|
341
|
+
throw new Error(`ticktick MCP tools/call ${name}: ${text ?? "unknown tool error"}`);
|
|
342
|
+
}
|
|
343
|
+
return message?.result ?? {};
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Send one request and parse the response message: a 429 is retried once
|
|
347
|
+
* after Retry-After, any other non-2xx status throws, and an empty body
|
|
348
|
+
* (notification success) parses to `undefined`.
|
|
349
|
+
* @param request - JSON-RPC payload.
|
|
350
|
+
* @param carryHeaders - include session/protocol headers (false for initialize).
|
|
351
|
+
* @returns the parsed message, or `undefined` for an empty body.
|
|
352
|
+
*/
|
|
353
|
+
async post(request, carryHeaders = true) {
|
|
354
|
+
const response = await this.send(request, carryHeaders);
|
|
355
|
+
const body = await response.text();
|
|
356
|
+
if (response.status !== 200 && response.status !== 202) throw new Error(`ticktick MCP request failed: HTTP ${response.status}: ${body.slice(0, 200)}`);
|
|
357
|
+
return parseMcpMessage(body);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Send one request, retrying once after Retry-After when the server
|
|
361
|
+
* answers 429.
|
|
362
|
+
* @param request - JSON-RPC payload.
|
|
363
|
+
* @param carryHeaders - include session/protocol headers (false for initialize).
|
|
364
|
+
* @returns the raw response (body not yet read).
|
|
365
|
+
*/
|
|
366
|
+
async send(request, carryHeaders = true) {
|
|
367
|
+
const headers = {
|
|
368
|
+
"content-type": "application/json",
|
|
369
|
+
accept: "application/json, text/event-stream",
|
|
370
|
+
authorization: `Bearer ${this.token}`
|
|
371
|
+
};
|
|
372
|
+
if (carryHeaders) {
|
|
373
|
+
if (this.sessionId !== void 0) headers["mcp-session-id"] = this.sessionId;
|
|
374
|
+
if (this.protocolVersion !== void 0) headers["mcp-protocol-version"] = this.protocolVersion;
|
|
375
|
+
}
|
|
376
|
+
const signal = AbortSignal.timeout(this.timeoutMs);
|
|
377
|
+
const send = async () => await this.fetchFn(this.url, {
|
|
378
|
+
method: "POST",
|
|
379
|
+
headers,
|
|
380
|
+
body: JSON.stringify(request),
|
|
381
|
+
signal
|
|
382
|
+
});
|
|
383
|
+
let response = await send();
|
|
384
|
+
if (response.status === 429) {
|
|
385
|
+
await delay(parseRetryAfter(response.headers.get("retry-after")), signal);
|
|
386
|
+
response = await send();
|
|
387
|
+
}
|
|
388
|
+
return response;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
/** Parse a Retry-After header (seconds or HTTP date), bounded to 60s. */
|
|
392
|
+
function parseRetryAfter(value) {
|
|
393
|
+
if (value === null) return 1e3;
|
|
394
|
+
const seconds = Number(value);
|
|
395
|
+
if (Number.isFinite(seconds)) return Math.min(Math.max(seconds * 1e3, 0), 6e4);
|
|
396
|
+
const epoch = Date.parse(value);
|
|
397
|
+
if (Number.isFinite(epoch)) return Math.min(Math.max(epoch - Date.now(), 0), 6e4);
|
|
398
|
+
return 1e3;
|
|
399
|
+
}
|
|
400
|
+
/** Abortable delay (bounded by the request timeout signal). */
|
|
401
|
+
function delay(ms, signal) {
|
|
402
|
+
if (ms <= 0) return Promise.resolve();
|
|
403
|
+
return new Promise((resolve, reject) => {
|
|
404
|
+
const timer = setTimeout(() => {
|
|
405
|
+
signal.removeEventListener("abort", onAbort);
|
|
406
|
+
resolve();
|
|
407
|
+
}, ms);
|
|
408
|
+
const onAbort = () => {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
reject(signal.reason);
|
|
411
|
+
};
|
|
412
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/service.ts
|
|
417
|
+
/** The epoch sentinel TickTick documents for clearing a due date. */
|
|
418
|
+
const EPOCH_SENTINEL = "1970-01-01T00:00:00.000+0000";
|
|
419
|
+
/** update_task server-side crash signature (Bug A): project tasks only. */
|
|
420
|
+
const UPDATE_CRASH_SIGNATURE = /expecting value|line 1 column 1|char 0|json/i;
|
|
421
|
+
/** Whether an error message names an auth failure. */
|
|
422
|
+
const AUTH_SIGNATURE = /401|unauthor|invalid token|token/i;
|
|
423
|
+
/** Project one normalized task into the wire shape (`null` for absent fields). */
|
|
424
|
+
function toWireTask(task) {
|
|
425
|
+
return {
|
|
426
|
+
id: task.id,
|
|
427
|
+
title: task.title,
|
|
428
|
+
done: task.done,
|
|
429
|
+
projectId: task.projectId ?? null,
|
|
430
|
+
dueDate: task.dueDate ?? null,
|
|
431
|
+
sortOrder: task.sortOrder ?? null
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Host service for the TickTick bridge: status plus the seven task
|
|
436
|
+
* operations, exported over the `ticktick` Remote namespace (hand-written
|
|
437
|
+
* `./typert` manifest, no decorators).
|
|
438
|
+
*/
|
|
439
|
+
var TicktickService = class extends TypertRemoteService {
|
|
440
|
+
config;
|
|
441
|
+
createClient;
|
|
442
|
+
/** The MCP client, lazily bootstrapped per token. */
|
|
443
|
+
client;
|
|
444
|
+
/** Resolved tool names for the bootstrapped client. */
|
|
445
|
+
resolver;
|
|
446
|
+
/** Token the current client was built for. */
|
|
447
|
+
tokenAtBoot;
|
|
448
|
+
/** Most recent bridge error message; `null` when the last call settled. */
|
|
449
|
+
lastError = null;
|
|
450
|
+
/** Whether the current token has produced a boot error (avoid hot loops). */
|
|
451
|
+
bootFailed = false;
|
|
452
|
+
/**
|
|
453
|
+
* @param ctx - owning Cordis context.
|
|
454
|
+
* @param config - runtime policy (token getter, endpoint, timeout, guards, pins).
|
|
455
|
+
* @param createClient - test seam: client factory (defaults to the real MCP client).
|
|
456
|
+
*/
|
|
457
|
+
constructor(ctx, config, createClient = (url, token, timeoutMs) => new McpStreamableClient(url, token, timeoutMs)) {
|
|
458
|
+
super(ctx, "ticktick");
|
|
459
|
+
this.config = config;
|
|
460
|
+
this.createClient = createClient;
|
|
461
|
+
}
|
|
462
|
+
/** Connection and configuration facts (no handshake forced). */
|
|
463
|
+
status() {
|
|
464
|
+
const resolver = this.resolver;
|
|
465
|
+
return {
|
|
466
|
+
configured: this.config.getToken() !== null,
|
|
467
|
+
connected: this.client !== void 0,
|
|
468
|
+
mcpUrl: this.config.mcpUrl,
|
|
469
|
+
lastError: this.lastError,
|
|
470
|
+
toolNames: {
|
|
471
|
+
projects: resolver?.projects ?? "",
|
|
472
|
+
tasks: resolver?.tasks ?? "",
|
|
473
|
+
create: resolver?.create ?? "",
|
|
474
|
+
complete: resolver?.complete ?? "",
|
|
475
|
+
remove: resolver?.remove ?? "",
|
|
476
|
+
update: resolver?.update ?? "",
|
|
477
|
+
move: resolver?.move ?? ""
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** Drop the bootstrapped client so the next call re-reads the token. */
|
|
482
|
+
reset() {
|
|
483
|
+
this.client = void 0;
|
|
484
|
+
this.resolver = void 0;
|
|
485
|
+
this.tokenAtBoot = void 0;
|
|
486
|
+
this.bootFailed = false;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* One-shot connectivity check over a throwaway client (the cached bridge
|
|
490
|
+
* client is untouched): initialize + tool listing against the current
|
|
491
|
+
* token. Feeds the settings card's "test connection" button.
|
|
492
|
+
*/
|
|
493
|
+
async probe() {
|
|
494
|
+
const token = this.config.getToken();
|
|
495
|
+
if (token === null) return {
|
|
496
|
+
ok: false,
|
|
497
|
+
toolCount: 0,
|
|
498
|
+
error: "no token configured"
|
|
499
|
+
};
|
|
500
|
+
const client = this.createClient(this.config.mcpUrl, token, this.config.toolCallTimeoutMs);
|
|
501
|
+
try {
|
|
502
|
+
await client.initialize();
|
|
503
|
+
return {
|
|
504
|
+
ok: true,
|
|
505
|
+
toolCount: (await client.listTools()).length,
|
|
506
|
+
error: null
|
|
507
|
+
};
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return {
|
|
510
|
+
ok: false,
|
|
511
|
+
toolCount: 0,
|
|
512
|
+
error: error instanceof Error ? error.message : String(error)
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** List the user's projects (the virtual inbox included). */
|
|
517
|
+
async projects() {
|
|
518
|
+
const { client, resolver } = await this.ensure();
|
|
519
|
+
return { projects: normalizeProjects(await this.call(client, resolver.projects, {})) };
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Aggregate undone tasks: one project when named, else every project.
|
|
523
|
+
* Projects whose response fails server-side validation are skipped and
|
|
524
|
+
* reported as warnings; each project's chunk stays sorted by descending
|
|
525
|
+
* sortOrder (the measured list order).
|
|
526
|
+
*/
|
|
527
|
+
async tasks(projectId) {
|
|
528
|
+
const { client, resolver } = await this.ensure();
|
|
529
|
+
const projects = normalizeProjects(await this.call(client, resolver.projects, {})).filter((project) => projectId === void 0 || projectId === "" || project.id === projectId);
|
|
530
|
+
const tasks = [];
|
|
531
|
+
const warnings = [];
|
|
532
|
+
for (const project of projects) try {
|
|
533
|
+
const chunk = normalizeTasks(await this.call(client, resolver.tasks, { project_id: project.id })).map((task) => toWireTask(task.projectId === void 0 ? {
|
|
534
|
+
...task,
|
|
535
|
+
projectId: project.id
|
|
536
|
+
} : task)).sort((a, b) => (b.sortOrder ?? 0) - (a.sortOrder ?? 0));
|
|
537
|
+
tasks.push(...chunk);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
warnings.push(`${project.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
tasks,
|
|
543
|
+
warnings
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Completed tasks within a window (P2): `list_completed_tasks_by_date`
|
|
548
|
+
* with a startDate `days` back (default 30) and an optional project.
|
|
549
|
+
*/
|
|
550
|
+
async completed(projectId, days = 30) {
|
|
551
|
+
const { client, resolver } = await this.ensure();
|
|
552
|
+
if (resolver.completed === "") throw new RemoteError("ticktick/tool-error", "the MCP endpoint does not advertise a completed-tasks tool", { tool: "completed" });
|
|
553
|
+
const search = { startDate: (/* @__PURE__ */ new Date(Date.now() - days * 864e5)).toISOString().slice(0, 10) };
|
|
554
|
+
if (projectId !== void 0 && projectId !== "") search.projectIds = [projectId];
|
|
555
|
+
return {
|
|
556
|
+
tasks: normalizeTasks(await this.call(client, resolver.completed, { search })).map(toWireTask),
|
|
557
|
+
warnings: []
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** Full-text search over TickTick tasks (P2): `search` / `search_task`. */
|
|
561
|
+
async search(query) {
|
|
562
|
+
const clean = query.trim();
|
|
563
|
+
if (clean === "") throw new RemoteError("ticktick/bad-request", "query required", { field: "query" });
|
|
564
|
+
const { client, resolver } = await this.ensure();
|
|
565
|
+
if (resolver.search === "") throw new RemoteError("ticktick/tool-error", "the MCP endpoint does not advertise a search tool", { tool: "search" });
|
|
566
|
+
return {
|
|
567
|
+
tasks: normalizeTasks(await this.call(client, resolver.search, { query: clean })).map(toWireTask),
|
|
568
|
+
warnings: []
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
/** Batch-create tasks (P2): `batch_add_tasks` over the OpenTask rows. */
|
|
572
|
+
async batchAdd(tasks) {
|
|
573
|
+
const rows = tasks.map((task) => ({
|
|
574
|
+
...task,
|
|
575
|
+
title: task.title.trim()
|
|
576
|
+
})).filter((task) => task.title !== "");
|
|
577
|
+
if (rows.length === 0) throw new RemoteError("ticktick/bad-request", "at least one non-empty title required", { field: "tasks" });
|
|
578
|
+
const { client, resolver } = await this.ensure();
|
|
579
|
+
if (resolver.batchAdd === "") throw new RemoteError("ticktick/tool-error", "the MCP endpoint does not advertise a batch-add tool", { tool: "batchAdd" });
|
|
580
|
+
await this.call(client, resolver.batchAdd, { tasks: rows.map((row) => ({
|
|
581
|
+
title: row.title,
|
|
582
|
+
...row.projectId !== void 0 && row.projectId !== "" ? { projectId: row.projectId } : {},
|
|
583
|
+
...row.dueDate !== void 0 && row.dueDate !== "" ? { dueDate: row.dueDate } : {}
|
|
584
|
+
})) });
|
|
585
|
+
return { created: rows.length };
|
|
586
|
+
}
|
|
587
|
+
/** Create one task; a named project places it there, otherwise the Inbox. */
|
|
588
|
+
async add(title, projectId, dueDate) {
|
|
589
|
+
const clean = title.trim();
|
|
590
|
+
if (clean === "") throw new RemoteError("ticktick/bad-request", "title required", { field: "title" });
|
|
591
|
+
const { client, resolver } = await this.ensure();
|
|
592
|
+
const task = { title: clean };
|
|
593
|
+
if (projectId !== void 0 && projectId !== "") task.projectId = projectId;
|
|
594
|
+
if (dueDate !== void 0 && dueDate !== "") task.dueDate = dueDate;
|
|
595
|
+
const created = normalizeTasks(await this.call(client, resolver.create, { task }))[0];
|
|
596
|
+
let verifyWarning = null;
|
|
597
|
+
if (created !== void 0 && resolver.getTask !== "") try {
|
|
598
|
+
const rows = normalizeTasks(await this.call(client, resolver.getTask, { task_id: created.id }));
|
|
599
|
+
if (rows.length === 0) verifyWarning = "readback returned no task";
|
|
600
|
+
else if (rows[0].title !== clean) verifyWarning = `readback title mismatch: ${rows[0].title}`;
|
|
601
|
+
} catch {
|
|
602
|
+
verifyWarning = "readback failed (verification skipped)";
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
task: created === void 0 ? null : toWireTask(created),
|
|
606
|
+
verifyWarning
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** Mark one task complete (TickTick needs both the project and the task id). */
|
|
610
|
+
async complete(id, projectId) {
|
|
611
|
+
this.requireIds(id, projectId);
|
|
612
|
+
const { client, resolver } = await this.ensure();
|
|
613
|
+
await this.call(client, resolver.complete, {
|
|
614
|
+
project_id: projectId,
|
|
615
|
+
task_id: id
|
|
616
|
+
});
|
|
617
|
+
return { ok: true };
|
|
618
|
+
}
|
|
619
|
+
/** Delete one task (TickTick needs both the project and the task id). */
|
|
620
|
+
async remove(id, projectId) {
|
|
621
|
+
this.requireIds(id, projectId);
|
|
622
|
+
const { client, resolver } = await this.ensure();
|
|
623
|
+
await this.call(client, resolver.remove, {
|
|
624
|
+
project_id: projectId,
|
|
625
|
+
task_id: id
|
|
626
|
+
});
|
|
627
|
+
return { ok: true };
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Set (or clear) one task's due date. Inbox tasks update directly.
|
|
631
|
+
* Project tasks try the direct update first and fall back to the measured
|
|
632
|
+
* move-to-inbox → update → move-back detour when the server answers with
|
|
633
|
+
* its update_task crash signature.
|
|
634
|
+
*/
|
|
635
|
+
async setDue(id, projectId, dueDate) {
|
|
636
|
+
if (id === "") throw new RemoteError("ticktick/bad-request", "id required", { field: "id" });
|
|
637
|
+
this.assertNotProtected(id);
|
|
638
|
+
const effective = dueDate !== void 0 && dueDate !== "" ? dueDate : EPOCH_SENTINEL;
|
|
639
|
+
const { client, resolver } = await this.ensure();
|
|
640
|
+
if (!(projectId !== void 0 && projectId !== "" && !isInboxProject(projectId))) {
|
|
641
|
+
await this.call(client, resolver.update, {
|
|
642
|
+
task_id: id,
|
|
643
|
+
task: { dueDate: effective }
|
|
644
|
+
});
|
|
645
|
+
return { ok: true };
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
await this.call(client, resolver.update, {
|
|
649
|
+
task_id: id,
|
|
650
|
+
task: { dueDate: effective }
|
|
651
|
+
});
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (!UPDATE_CRASH_SIGNATURE.test(error instanceof Error ? error.message : String(error))) throw error;
|
|
654
|
+
await this.call(client, resolver.move, { moves: [{
|
|
655
|
+
fromProjectId: projectId,
|
|
656
|
+
toProjectId: "inbox",
|
|
657
|
+
taskId: id
|
|
658
|
+
}] });
|
|
659
|
+
try {
|
|
660
|
+
await this.call(client, resolver.update, {
|
|
661
|
+
task_id: id,
|
|
662
|
+
task: { dueDate: effective }
|
|
663
|
+
});
|
|
664
|
+
} finally {
|
|
665
|
+
await this.call(client, resolver.move, { moves: [{
|
|
666
|
+
fromProjectId: "inbox",
|
|
667
|
+
toProjectId: projectId,
|
|
668
|
+
taskId: id
|
|
669
|
+
}] });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return { ok: true };
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Move one task to a new position: inbox tasks take update_task with the
|
|
676
|
+
* new sortOrder; project tasks take a same-project move carrying the new
|
|
677
|
+
* sortOrder (the list order is descending sortOrder).
|
|
678
|
+
*/
|
|
679
|
+
async reorder(id, projectId, sortOrder) {
|
|
680
|
+
if (id === "") throw new RemoteError("ticktick/bad-request", "id required", { field: "id" });
|
|
681
|
+
if (!Number.isFinite(sortOrder) || !Number.isInteger(sortOrder)) throw new RemoteError("ticktick/bad-request", "sortOrder must be an integer", { field: "sortOrder" });
|
|
682
|
+
this.assertNotProtected(id);
|
|
683
|
+
const { client, resolver } = await this.ensure();
|
|
684
|
+
if (projectId !== void 0 && projectId !== "" && !isInboxProject(projectId)) await this.call(client, resolver.move, { moves: [{
|
|
685
|
+
fromProjectId: projectId,
|
|
686
|
+
toProjectId: projectId,
|
|
687
|
+
taskId: id,
|
|
688
|
+
sortOrder
|
|
689
|
+
}] });
|
|
690
|
+
else await this.call(client, resolver.update, {
|
|
691
|
+
task_id: id,
|
|
692
|
+
task: { sortOrder }
|
|
693
|
+
});
|
|
694
|
+
return { ok: true };
|
|
695
|
+
}
|
|
696
|
+
/** Reject a mutating operation naming an id on the protected list. */
|
|
697
|
+
assertNotProtected(id) {
|
|
698
|
+
if (this.config.protectedTaskIds.includes(id)) throw new RemoteError("ticktick/protected", `task ${id} is protected`, { taskId: id });
|
|
699
|
+
}
|
|
700
|
+
/** Validate the id pair required by complete/delete. */
|
|
701
|
+
requireIds(id, projectId) {
|
|
702
|
+
if (id === "") throw new RemoteError("ticktick/bad-request", "id required", { field: "id" });
|
|
703
|
+
if (projectId === "") throw new RemoteError("ticktick/bad-request", "projectId required", { field: "projectId" });
|
|
704
|
+
this.assertNotProtected(id);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Lazily bootstrap the MCP client and tool resolver for the active token.
|
|
708
|
+
* A token change or a 401-triggered reset rebuilds the client.
|
|
709
|
+
*/
|
|
710
|
+
async ensure() {
|
|
711
|
+
const token = this.config.getToken();
|
|
712
|
+
if (token === null) throw new RemoteError("ticktick/no-token", "no token: set it in the TickTick settings card or write the API 口令 to the token file", {});
|
|
713
|
+
if (this.client === void 0 || this.tokenAtBoot !== token || this.bootFailed) {
|
|
714
|
+
this.client = void 0;
|
|
715
|
+
this.resolver = void 0;
|
|
716
|
+
this.tokenAtBoot = void 0;
|
|
717
|
+
this.bootFailed = false;
|
|
718
|
+
const client = this.createClient(this.config.mcpUrl, token, this.config.toolCallTimeoutMs);
|
|
719
|
+
try {
|
|
720
|
+
await client.initialize();
|
|
721
|
+
const tools = await client.listTools();
|
|
722
|
+
this.resolver = resolveTools(tools, this.config.pins);
|
|
723
|
+
this.client = client;
|
|
724
|
+
this.tokenAtBoot = token;
|
|
725
|
+
this.lastError = null;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.bootFailed = true;
|
|
728
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
729
|
+
throw new RemoteError("ticktick/tool-error", this.lastError, { tool: "initialize" }, { cause: error });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
client: this.client,
|
|
734
|
+
resolver: this.resolver
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* One tools/call with error mapping: tool-level failures carry their raw
|
|
739
|
+
* tool name; a 401 signature resets the client once so the next call
|
|
740
|
+
* re-reads the token file.
|
|
741
|
+
*/
|
|
742
|
+
async call(client, tool, args) {
|
|
743
|
+
try {
|
|
744
|
+
this.lastError = null;
|
|
745
|
+
return await client.callTool(tool, args);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
748
|
+
this.lastError = message;
|
|
749
|
+
if (AUTH_SIGNATURE.test(message)) this.reset();
|
|
750
|
+
throw new RemoteError("ticktick/tool-error", message, { tool }, { cause: error });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
//#endregion
|
|
755
|
+
//#region src/tools.ts
|
|
756
|
+
/**
|
|
757
|
+
* The curated model-facing tools of the TickTick bridge. All eight reuse the
|
|
758
|
+
* host service's business methods, so the model and the widget share one
|
|
759
|
+
* policy (protected ids, workarounds, error codes). Each `output.schema`
|
|
760
|
+
* returns a structured JSON value — the Code Mode `await tools.ticktick_*`
|
|
761
|
+
* surface derives from it — while `output.render` carries the human text.
|
|
762
|
+
*
|
|
763
|
+
* @module dsh-ticktick/tools
|
|
764
|
+
*/
|
|
765
|
+
/** Task wire projection shared by the add/tasks tools. */
|
|
766
|
+
function taskWire(task) {
|
|
767
|
+
const due = task.dueDate === null ? "no due date" : task.dueDate;
|
|
768
|
+
return `${task.title} [${task.projectId ?? "?"}] due ${due}`;
|
|
769
|
+
}
|
|
770
|
+
/** Value schema of one task wire row. */
|
|
771
|
+
const TASK_ROW_SCHEMA = {
|
|
772
|
+
type: "object",
|
|
773
|
+
properties: {
|
|
774
|
+
id: {
|
|
775
|
+
type: "string",
|
|
776
|
+
required: true
|
|
777
|
+
},
|
|
778
|
+
title: {
|
|
779
|
+
type: "string",
|
|
780
|
+
required: true
|
|
781
|
+
},
|
|
782
|
+
done: {
|
|
783
|
+
type: "boolean",
|
|
784
|
+
required: true
|
|
785
|
+
},
|
|
786
|
+
projectId: {
|
|
787
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
788
|
+
required: true
|
|
789
|
+
},
|
|
790
|
+
dueDate: {
|
|
791
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
792
|
+
required: true
|
|
793
|
+
},
|
|
794
|
+
sortOrder: {
|
|
795
|
+
oneOf: [{ type: "number" }, { type: "null" }],
|
|
796
|
+
required: true
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
additionalProperties: false
|
|
800
|
+
};
|
|
801
|
+
/** Value schema of an `{ ok: boolean }` mutation result. */
|
|
802
|
+
const OK_RESULT_SCHEMA = {
|
|
803
|
+
type: "object",
|
|
804
|
+
properties: { ok: {
|
|
805
|
+
type: "boolean",
|
|
806
|
+
required: true
|
|
807
|
+
} },
|
|
808
|
+
additionalProperties: false
|
|
809
|
+
};
|
|
810
|
+
/**
|
|
811
|
+
* Build the eight tools bound to one service instance.
|
|
812
|
+
* @param service - the mounted TickTick service.
|
|
813
|
+
* @returns the registered tool definitions.
|
|
814
|
+
*/
|
|
815
|
+
function buildTicktickTools(service) {
|
|
816
|
+
return [
|
|
817
|
+
defineTool({
|
|
818
|
+
name: "ticktick_status",
|
|
819
|
+
description: "Read the TickTick (Dida365) bridge connection state: token configured, client connected, endpoint, and last error.",
|
|
820
|
+
parameters: {},
|
|
821
|
+
output: {
|
|
822
|
+
schema: {
|
|
823
|
+
type: "object",
|
|
824
|
+
properties: {
|
|
825
|
+
configured: {
|
|
826
|
+
type: "boolean",
|
|
827
|
+
required: true
|
|
828
|
+
},
|
|
829
|
+
connected: {
|
|
830
|
+
type: "boolean",
|
|
831
|
+
required: true
|
|
832
|
+
},
|
|
833
|
+
mcpUrl: {
|
|
834
|
+
type: "string",
|
|
835
|
+
required: true
|
|
836
|
+
},
|
|
837
|
+
lastError: {
|
|
838
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
839
|
+
required: true
|
|
840
|
+
}
|
|
841
|
+
},
|
|
842
|
+
additionalProperties: false
|
|
843
|
+
},
|
|
844
|
+
render: (_args, value) => {
|
|
845
|
+
const status = value;
|
|
846
|
+
const line = status.configured ? status.connected ? `connected to ${status.mcpUrl}` : "configured but not connected" : "not configured (no token)";
|
|
847
|
+
return [{
|
|
848
|
+
type: "text",
|
|
849
|
+
text: status.lastError === null ? `TickTick bridge: ${line}` : `TickTick bridge: ${line}; last error: ${status.lastError}`
|
|
850
|
+
}];
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
async execute() {
|
|
854
|
+
const status = service.status();
|
|
855
|
+
return {
|
|
856
|
+
configured: status.configured,
|
|
857
|
+
connected: status.connected,
|
|
858
|
+
mcpUrl: status.mcpUrl,
|
|
859
|
+
lastError: status.lastError
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}),
|
|
863
|
+
defineTool({
|
|
864
|
+
name: "ticktick_lists",
|
|
865
|
+
description: "List every TickTick list (project), including the virtual Inbox.",
|
|
866
|
+
parameters: {},
|
|
867
|
+
output: {
|
|
868
|
+
schema: {
|
|
869
|
+
type: "object",
|
|
870
|
+
properties: { projects: {
|
|
871
|
+
type: "array",
|
|
872
|
+
required: true,
|
|
873
|
+
items: {
|
|
874
|
+
type: "object",
|
|
875
|
+
properties: {
|
|
876
|
+
id: {
|
|
877
|
+
type: "string",
|
|
878
|
+
required: true
|
|
879
|
+
},
|
|
880
|
+
name: {
|
|
881
|
+
type: "string",
|
|
882
|
+
required: true
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
additionalProperties: false
|
|
886
|
+
}
|
|
887
|
+
} },
|
|
888
|
+
additionalProperties: false
|
|
889
|
+
},
|
|
890
|
+
render: (_args, value) => {
|
|
891
|
+
const { projects } = value;
|
|
892
|
+
return [{
|
|
893
|
+
type: "text",
|
|
894
|
+
text: projects.length === 0 ? "no lists" : projects.map((p) => `${p.name} (${p.id})`).join("\n")
|
|
895
|
+
}];
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
async execute() {
|
|
899
|
+
return { projects: [...(await service.projects()).projects] };
|
|
900
|
+
}
|
|
901
|
+
}),
|
|
902
|
+
defineTool({
|
|
903
|
+
name: "ticktick_tasks",
|
|
904
|
+
description: "List undone TickTick tasks: one named list, or every list aggregated with per-list warnings for lists the server could not read.",
|
|
905
|
+
parameters: { projectId: {
|
|
906
|
+
type: "string",
|
|
907
|
+
description: "Optional list id (omit for every list)"
|
|
908
|
+
} },
|
|
909
|
+
output: {
|
|
910
|
+
schema: {
|
|
911
|
+
type: "object",
|
|
912
|
+
properties: {
|
|
913
|
+
tasks: {
|
|
914
|
+
type: "array",
|
|
915
|
+
required: true,
|
|
916
|
+
items: TASK_ROW_SCHEMA
|
|
917
|
+
},
|
|
918
|
+
warnings: {
|
|
919
|
+
type: "array",
|
|
920
|
+
required: true,
|
|
921
|
+
items: { type: "string" }
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
additionalProperties: false
|
|
925
|
+
},
|
|
926
|
+
render: (_args, value) => {
|
|
927
|
+
const { tasks, warnings } = value;
|
|
928
|
+
const lines = tasks.length === 0 ? ["no undone tasks"] : tasks.map(taskWire);
|
|
929
|
+
if (warnings.length > 0) lines.push(...warnings.map((w) => `warning: ${w}`));
|
|
930
|
+
return [{
|
|
931
|
+
type: "text",
|
|
932
|
+
text: lines.join("\n")
|
|
933
|
+
}];
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
async execute(args) {
|
|
937
|
+
const result = await service.tasks(args.projectId);
|
|
938
|
+
return {
|
|
939
|
+
tasks: [...result.tasks],
|
|
940
|
+
warnings: [...result.warnings]
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
}),
|
|
944
|
+
defineTool({
|
|
945
|
+
name: "ticktick_add",
|
|
946
|
+
description: "Create one TickTick task: a named list places it there, otherwise the Inbox.",
|
|
947
|
+
parameters: {
|
|
948
|
+
title: {
|
|
949
|
+
type: "string",
|
|
950
|
+
required: true,
|
|
951
|
+
description: "Task title"
|
|
952
|
+
},
|
|
953
|
+
projectId: {
|
|
954
|
+
type: "string",
|
|
955
|
+
description: "Optional list id (default Inbox)"
|
|
956
|
+
},
|
|
957
|
+
dueDate: {
|
|
958
|
+
type: "string",
|
|
959
|
+
description: "Optional ISO due date (e.g. 2026-09-05 or a full ISO date-time)"
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
output: {
|
|
963
|
+
schema: {
|
|
964
|
+
type: "object",
|
|
965
|
+
properties: {
|
|
966
|
+
task: {
|
|
967
|
+
oneOf: [TASK_ROW_SCHEMA, { type: "null" }],
|
|
968
|
+
required: true
|
|
969
|
+
},
|
|
970
|
+
verifyWarning: {
|
|
971
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
972
|
+
required: true
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
additionalProperties: false
|
|
976
|
+
},
|
|
977
|
+
render: (_args, value) => {
|
|
978
|
+
const { task, verifyWarning } = value;
|
|
979
|
+
const line = task === null ? "task created (server returned no echo)" : `created: ${taskWire(task)}`;
|
|
980
|
+
return [{
|
|
981
|
+
type: "text",
|
|
982
|
+
text: verifyWarning === null ? line : `${line}; verification warning: ${verifyWarning}`
|
|
983
|
+
}];
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
async execute(args) {
|
|
987
|
+
return service.add(args.title, args.projectId, args.dueDate);
|
|
988
|
+
}
|
|
989
|
+
}),
|
|
990
|
+
defineTool({
|
|
991
|
+
name: "ticktick_complete",
|
|
992
|
+
description: "Mark one TickTick task complete (both the task id and its list id are required).",
|
|
993
|
+
parameters: {
|
|
994
|
+
id: {
|
|
995
|
+
type: "string",
|
|
996
|
+
required: true,
|
|
997
|
+
description: "Task id"
|
|
998
|
+
},
|
|
999
|
+
projectId: {
|
|
1000
|
+
type: "string",
|
|
1001
|
+
required: true,
|
|
1002
|
+
description: "Owning list id"
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
output: {
|
|
1006
|
+
schema: OK_RESULT_SCHEMA,
|
|
1007
|
+
render: (_args, value) => {
|
|
1008
|
+
const { ok } = value;
|
|
1009
|
+
return [{
|
|
1010
|
+
type: "text",
|
|
1011
|
+
text: ok ? "task completed" : "completion rejected"
|
|
1012
|
+
}];
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
async execute(args) {
|
|
1016
|
+
return service.complete(args.id, args.projectId);
|
|
1017
|
+
}
|
|
1018
|
+
}),
|
|
1019
|
+
defineTool({
|
|
1020
|
+
name: "ticktick_delete",
|
|
1021
|
+
description: "Delete one TickTick task (both the task id and its list id are required).",
|
|
1022
|
+
parameters: {
|
|
1023
|
+
id: {
|
|
1024
|
+
type: "string",
|
|
1025
|
+
required: true,
|
|
1026
|
+
description: "Task id"
|
|
1027
|
+
},
|
|
1028
|
+
projectId: {
|
|
1029
|
+
type: "string",
|
|
1030
|
+
required: true,
|
|
1031
|
+
description: "Owning list id"
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
output: {
|
|
1035
|
+
schema: OK_RESULT_SCHEMA,
|
|
1036
|
+
render: (_args, value) => {
|
|
1037
|
+
const { ok } = value;
|
|
1038
|
+
return [{
|
|
1039
|
+
type: "text",
|
|
1040
|
+
text: ok ? "task deleted" : "delete rejected"
|
|
1041
|
+
}];
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
async execute(args) {
|
|
1045
|
+
return service.remove(args.id, args.projectId);
|
|
1046
|
+
}
|
|
1047
|
+
}),
|
|
1048
|
+
defineTool({
|
|
1049
|
+
name: "ticktick_due",
|
|
1050
|
+
description: "Set or clear one TickTick task due date. Omit dueDate to clear. Project tasks fall back to a move-to-inbox detour when the server rejects the direct update.",
|
|
1051
|
+
parameters: {
|
|
1052
|
+
id: {
|
|
1053
|
+
type: "string",
|
|
1054
|
+
required: true,
|
|
1055
|
+
description: "Task id"
|
|
1056
|
+
},
|
|
1057
|
+
projectId: {
|
|
1058
|
+
type: "string",
|
|
1059
|
+
description: "Owning list id (required for project tasks)"
|
|
1060
|
+
},
|
|
1061
|
+
dueDate: {
|
|
1062
|
+
type: "string",
|
|
1063
|
+
description: "ISO due date; omit to clear"
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
output: {
|
|
1067
|
+
schema: OK_RESULT_SCHEMA,
|
|
1068
|
+
render: (_args, value) => {
|
|
1069
|
+
const { ok } = value;
|
|
1070
|
+
return [{
|
|
1071
|
+
type: "text",
|
|
1072
|
+
text: ok ? "due date applied" : "due date rejected"
|
|
1073
|
+
}];
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
async execute(args) {
|
|
1077
|
+
return service.setDue(args.id, args.projectId, args.dueDate);
|
|
1078
|
+
}
|
|
1079
|
+
}),
|
|
1080
|
+
defineTool({
|
|
1081
|
+
name: "ticktick_reorder",
|
|
1082
|
+
description: "Move one TickTick task to a new sortOrder (lists order by descending sortOrder; compute the target from the neighboring task).",
|
|
1083
|
+
parameters: {
|
|
1084
|
+
id: {
|
|
1085
|
+
type: "string",
|
|
1086
|
+
required: true,
|
|
1087
|
+
description: "Task id"
|
|
1088
|
+
},
|
|
1089
|
+
projectId: {
|
|
1090
|
+
type: "string",
|
|
1091
|
+
description: "Owning list id (inbox tasks omit it)"
|
|
1092
|
+
},
|
|
1093
|
+
sortOrder: {
|
|
1094
|
+
type: "integer",
|
|
1095
|
+
required: true,
|
|
1096
|
+
description: "Integer sortOrder to assign"
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
output: {
|
|
1100
|
+
schema: OK_RESULT_SCHEMA,
|
|
1101
|
+
render: (_args, value) => {
|
|
1102
|
+
const { ok } = value;
|
|
1103
|
+
return [{
|
|
1104
|
+
type: "text",
|
|
1105
|
+
text: ok ? "task reordered" : "reorder rejected"
|
|
1106
|
+
}];
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
async execute(args) {
|
|
1110
|
+
return service.reorder(args.id, args.projectId, args.sortOrder);
|
|
1111
|
+
}
|
|
1112
|
+
}),
|
|
1113
|
+
defineTool({
|
|
1114
|
+
name: "ticktick_completed",
|
|
1115
|
+
description: "List TickTick tasks completed within a window (default the last 30 days), optionally narrowed to one list.",
|
|
1116
|
+
parameters: {
|
|
1117
|
+
projectId: {
|
|
1118
|
+
type: "string",
|
|
1119
|
+
description: "Optional list id (omit for every list)"
|
|
1120
|
+
},
|
|
1121
|
+
days: {
|
|
1122
|
+
type: "integer",
|
|
1123
|
+
description: "Window length in days (default 30)"
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
output: {
|
|
1127
|
+
schema: {
|
|
1128
|
+
type: "object",
|
|
1129
|
+
properties: {
|
|
1130
|
+
tasks: {
|
|
1131
|
+
type: "array",
|
|
1132
|
+
required: true,
|
|
1133
|
+
items: TASK_ROW_SCHEMA
|
|
1134
|
+
},
|
|
1135
|
+
warnings: {
|
|
1136
|
+
type: "array",
|
|
1137
|
+
required: true,
|
|
1138
|
+
items: { type: "string" }
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
additionalProperties: false
|
|
1142
|
+
},
|
|
1143
|
+
render: (_args, value) => {
|
|
1144
|
+
const { tasks, warnings } = value;
|
|
1145
|
+
const lines = tasks.length === 0 ? ["no completed tasks in the window"] : tasks.map(taskWire);
|
|
1146
|
+
if (warnings.length > 0) lines.push(...warnings.map((w) => `warning: ${w}`));
|
|
1147
|
+
return [{
|
|
1148
|
+
type: "text",
|
|
1149
|
+
text: lines.join("\n")
|
|
1150
|
+
}];
|
|
1151
|
+
}
|
|
1152
|
+
},
|
|
1153
|
+
async execute(args) {
|
|
1154
|
+
const result = await service.completed(args.projectId, args.days);
|
|
1155
|
+
return {
|
|
1156
|
+
tasks: [...result.tasks],
|
|
1157
|
+
warnings: [...result.warnings]
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
}),
|
|
1161
|
+
defineTool({
|
|
1162
|
+
name: "ticktick_search",
|
|
1163
|
+
description: "Full-text search over TickTick tasks (the official search / search_task tool).",
|
|
1164
|
+
parameters: { query: {
|
|
1165
|
+
type: "string",
|
|
1166
|
+
required: true,
|
|
1167
|
+
description: "Search keyword"
|
|
1168
|
+
} },
|
|
1169
|
+
output: {
|
|
1170
|
+
schema: {
|
|
1171
|
+
type: "object",
|
|
1172
|
+
properties: {
|
|
1173
|
+
tasks: {
|
|
1174
|
+
type: "array",
|
|
1175
|
+
required: true,
|
|
1176
|
+
items: TASK_ROW_SCHEMA
|
|
1177
|
+
},
|
|
1178
|
+
warnings: {
|
|
1179
|
+
type: "array",
|
|
1180
|
+
required: true,
|
|
1181
|
+
items: { type: "string" }
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
additionalProperties: false
|
|
1185
|
+
},
|
|
1186
|
+
render: (_args, value) => {
|
|
1187
|
+
const { tasks } = value;
|
|
1188
|
+
return [{
|
|
1189
|
+
type: "text",
|
|
1190
|
+
text: (tasks.length === 0 ? ["no matches"] : tasks.map(taskWire)).join("\n")
|
|
1191
|
+
}];
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
async execute(args) {
|
|
1195
|
+
const result = await service.search(args.query);
|
|
1196
|
+
return {
|
|
1197
|
+
tasks: [...result.tasks],
|
|
1198
|
+
warnings: [...result.warnings]
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
}),
|
|
1202
|
+
defineTool({
|
|
1203
|
+
name: "ticktick_batch_add",
|
|
1204
|
+
description: "Create several TickTick tasks in one call (batch_add_tasks); rows carry title plus optional list id and ISO due date.",
|
|
1205
|
+
parameters: { tasks: {
|
|
1206
|
+
type: "array",
|
|
1207
|
+
required: true,
|
|
1208
|
+
description: "Task rows to create",
|
|
1209
|
+
items: {
|
|
1210
|
+
type: "object",
|
|
1211
|
+
properties: {
|
|
1212
|
+
title: {
|
|
1213
|
+
type: "string",
|
|
1214
|
+
required: true
|
|
1215
|
+
},
|
|
1216
|
+
projectId: { type: "string" },
|
|
1217
|
+
dueDate: { type: "string" }
|
|
1218
|
+
},
|
|
1219
|
+
additionalProperties: false
|
|
1220
|
+
}
|
|
1221
|
+
} },
|
|
1222
|
+
output: {
|
|
1223
|
+
schema: {
|
|
1224
|
+
type: "object",
|
|
1225
|
+
properties: { created: {
|
|
1226
|
+
type: "integer",
|
|
1227
|
+
required: true
|
|
1228
|
+
} },
|
|
1229
|
+
additionalProperties: false
|
|
1230
|
+
},
|
|
1231
|
+
render: (_args, value) => {
|
|
1232
|
+
const { created } = value;
|
|
1233
|
+
return [{
|
|
1234
|
+
type: "text",
|
|
1235
|
+
text: `created ${created} task${created === 1 ? "" : "s"}`
|
|
1236
|
+
}];
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
async execute(args) {
|
|
1240
|
+
return service.batchAdd(args.tasks);
|
|
1241
|
+
}
|
|
1242
|
+
})
|
|
1243
|
+
];
|
|
1244
|
+
}
|
|
1245
|
+
//#endregion
|
|
1246
|
+
//#region src/index.ts
|
|
1247
|
+
/**
|
|
1248
|
+
* `dsh-ticktick` — the TickTick (Dida365) task bridge for DeepSeek Harness.
|
|
1249
|
+
*
|
|
1250
|
+
* Host half: mounts the `ticktick` Remote service (status plus the seven
|
|
1251
|
+
* task operations over the official TickTick MCP endpoint), registers the
|
|
1252
|
+
* eight curated `ticktick_*` tools on the shared service, installs the
|
|
1253
|
+
* `ticktick` settings namespace (token / token file / endpoint / protected
|
|
1254
|
+
* ids) that the browser settings card edits, and announces the tool set in
|
|
1255
|
+
* one system-prompt section.
|
|
1256
|
+
*
|
|
1257
|
+
* The token is re-read per request: the settings card's secret field wins,
|
|
1258
|
+
* then the token file (`$DSH_HOME/.ticktick-token` by default). Writing the
|
|
1259
|
+
* file activates the bridge without a config change.
|
|
1260
|
+
*
|
|
1261
|
+
* Function plugin — no default export (the Loader unwraps
|
|
1262
|
+
* `exports.default ?? exports`).
|
|
1263
|
+
*
|
|
1264
|
+
* @module dsh-ticktick
|
|
1265
|
+
*/
|
|
1266
|
+
const name = "ticktick";
|
|
1267
|
+
/** Hard services: the tool registry and the prompt assembly. `settings` is an optional child. */
|
|
1268
|
+
const inject = ["tools", "systemPrompt"];
|
|
1269
|
+
/** Settings namespace the browser card edits (paired by this exact key). */
|
|
1270
|
+
const SETTINGS_NS = TICKTICK_SETTINGS_NS;
|
|
1271
|
+
/** Schemastery schema for the settings namespace (the card renders this). */
|
|
1272
|
+
const TicktickSettingsSchema = z.object({
|
|
1273
|
+
token: z.string().role("secret").default(""),
|
|
1274
|
+
tokenFile: z.string().default(""),
|
|
1275
|
+
mcpUrl: z.string().default(DEFAULT_MCP_URL),
|
|
1276
|
+
protectedTaskIds: z.array(z.string()).default([])
|
|
1277
|
+
});
|
|
1278
|
+
/** Default token file: `<DSH_HOME>/.ticktick-token` (or `~/.dsh/.ticktick-token`). */
|
|
1279
|
+
function defaultTokenFile() {
|
|
1280
|
+
return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), ".ticktick-token");
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Resolve the current Bearer token: the settings card's secret wins, then
|
|
1284
|
+
* the DIDA365_TOKEN environment variable, then the configured token file.
|
|
1285
|
+
* @param settings - current settings layer.
|
|
1286
|
+
* @param resolved - loader config.
|
|
1287
|
+
* @returns the token, or `null` when unconfigured.
|
|
1288
|
+
*/
|
|
1289
|
+
function resolveToken(settings, resolved) {
|
|
1290
|
+
if (settings.token !== "") return settings.token;
|
|
1291
|
+
const env = process.env.DIDA365_TOKEN;
|
|
1292
|
+
if (env !== void 0 && env.trim() !== "") return env.trim();
|
|
1293
|
+
const file = settings.tokenFile !== "" ? settings.tokenFile : resolved.tokenFile !== "" ? resolved.tokenFile : defaultTokenFile();
|
|
1294
|
+
if (!existsSync(file)) return null;
|
|
1295
|
+
const token = readFileSync(file, "utf8").trim();
|
|
1296
|
+
return token === "" ? null : token;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Mount the bridge: the settings namespace, the Remote service, the eight
|
|
1300
|
+
* tools, and the usage announcement.
|
|
1301
|
+
*
|
|
1302
|
+
* @param ctx - context carrying tools + systemPrompt.
|
|
1303
|
+
* @param config - raw loader config; defaults applied through {@link resolveConfig}.
|
|
1304
|
+
*/
|
|
1305
|
+
async function apply(ctx, config) {
|
|
1306
|
+
const resolved = resolveConfig(config);
|
|
1307
|
+
let settingsSource = () => ({
|
|
1308
|
+
token: "",
|
|
1309
|
+
tokenFile: resolved.tokenFile,
|
|
1310
|
+
mcpUrl: resolved.mcpUrl,
|
|
1311
|
+
protectedTaskIds: [...resolved.protectedTaskIds]
|
|
1312
|
+
});
|
|
1313
|
+
let service;
|
|
1314
|
+
ctx.inject(["settings"], (scope) => {
|
|
1315
|
+
scope.settings.installSection(ctx, SETTINGS_NS, TicktickSettingsSchema, {
|
|
1316
|
+
token: "",
|
|
1317
|
+
tokenFile: resolved.tokenFile,
|
|
1318
|
+
mcpUrl: resolved.mcpUrl,
|
|
1319
|
+
protectedTaskIds: [...resolved.protectedTaskIds]
|
|
1320
|
+
}, {
|
|
1321
|
+
validate: (value) => {
|
|
1322
|
+
if (value.mcpUrl.trim() === "") throw new Error("dsh-ticktick: mcpUrl must not be empty");
|
|
1323
|
+
},
|
|
1324
|
+
setSource: (source) => {
|
|
1325
|
+
settingsSource = source;
|
|
1326
|
+
},
|
|
1327
|
+
onChange: () => {
|
|
1328
|
+
service?.reset();
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
const serviceConfig = {
|
|
1333
|
+
getToken: () => resolveToken(settingsSource(), resolved),
|
|
1334
|
+
mcpUrl: resolved.mcpUrl,
|
|
1335
|
+
toolCallTimeoutMs: resolved.toolCallTimeoutMs,
|
|
1336
|
+
protectedTaskIds: resolved.protectedTaskIds,
|
|
1337
|
+
pins: resolved.tools
|
|
1338
|
+
};
|
|
1339
|
+
await ctx.plugin(TicktickService, serviceConfig);
|
|
1340
|
+
service = ctx.get("ticktick");
|
|
1341
|
+
for (const tool of buildTicktickTools(service)) ctx.effect(() => ctx.tools.register(tool), `dsh-ticktick: ${tool.name} tool`);
|
|
1342
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
1343
|
+
name: "ticktick:usage",
|
|
1344
|
+
order: 350,
|
|
1345
|
+
text: "TickTick (Dida365) tasks are available through the ticktick_* tools: ticktick_status, ticktick_lists, ticktick_tasks, ticktick_add, ticktick_complete, ticktick_delete, ticktick_due, ticktick_reorder, ticktick_completed, ticktick_search, ticktick_batch_add. The Session header also shows a TickTick panel for browsing lists (undone/completed views, full-text search), adding, completing, deleting, setting due dates, and drag reordering."
|
|
1346
|
+
}), "dsh-ticktick: system prompt section");
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
export { Config, DEFAULT_MCP_URL, DEFAULT_TOOL_CALL_TIMEOUT_MS, McpStreamableClient, SETTINGS_NS, TicktickService, TicktickSettingsSchema, apply, buildTicktickTools, inject, isInboxProject, name, normalizeProjects, normalizeTasks, parseMcpMessage, resolveConfig, resolveToken, resolveTools };
|