clokio-mcp 0.14.0 → 0.17.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/README.md +15 -3
- package/dist/markdown.js +151 -0
- package/dist/tools/tasks.js +231 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ acting. An explicit code on a tool call always takes precedence.
|
|
|
63
63
|
|
|
64
64
|
Optional: set `CLOKIO_TOOLSETS` to register only the tool groups you need, e.g.
|
|
65
65
|
`CLOKIO_TOOLSETS=tasks`. This cuts the per-session tool-list cost — a
|
|
66
|
-
task-focused session drops from
|
|
66
|
+
task-focused session drops from 50 tools to 31. Optional groups are `tasks`,
|
|
67
67
|
`employees` and `attendance` (which includes leave and time entries); combine
|
|
68
68
|
them with commas. The `projects` group (project/client lookups, task statuses
|
|
69
69
|
and labels, and `whoami`) is always on, because the task tools resolve names
|
|
@@ -71,12 +71,12 @@ through it.
|
|
|
71
71
|
|
|
72
72
|
## Tools
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
50 tools across five areas. Read-only tools carry the MCP `readOnlyHint`; writes
|
|
75
75
|
are marked mutating (deletes are `destructive`).
|
|
76
76
|
|
|
77
77
|
| Area | Tools |
|
|
78
78
|
|------|-------|
|
|
79
|
-
| **Tasks** | list (with `compact` / `sort` / `updated_since`), get (with `comments_limit`), create, update, delete, bulk update, set assignees, list/add/edit comments, list attachments (with `is_latest` + `latest_only`), upload, download one (save to disk, or read PDF text / ZIP entries / images / text inline), download all to a folder, activity, list/add/remove dependencies, set custom field |
|
|
79
|
+
| **Tasks** | list (with `compact` / `sort` / `updated_since` / `recurring`), get (with `comments_limit`), create, update (incl. recurring series: set a rule, pause/resume, stop), delete, bulk update, set assignees, list/add/edit comments, list attachments (with `is_latest` + `latest_only`), upload, download one (save to disk, or read PDF text / ZIP entries / images / text inline), download all to a folder, activity, list/add/remove dependencies, set custom field |
|
|
80
80
|
| **Projects & clients** | list projects, project statuses, project custom fields, project contacts, get client, add client contact |
|
|
81
81
|
| **Reference** | task statuses, task labels, locations, list webhooks (read-only), whoami |
|
|
82
82
|
| **Employees** | list (name search), lookup by PIN, task-stats, inbox (mentions, new assignments, others' status changes), create, set status |
|
|
@@ -91,8 +91,20 @@ are marked mutating (deletes are `destructive`).
|
|
|
91
91
|
issuer (see `clokio_whoami`), so you rarely set an author. Pass
|
|
92
92
|
`created_by_employee_code` / `author_employee_code` only to credit someone
|
|
93
93
|
else, or set `CLOKIO_DEFAULT_ACTOR` for a shared key.
|
|
94
|
+
- **Write Markdown.** Descriptions and comment bodies accept Markdown (paragraphs, lists,
|
|
95
|
+
`**bold**`, `` `code` ``, links, headings, fenced code); the server converts it to the HTML
|
|
96
|
+
Clokio stores. Text that already starts with an HTML tag is passed through untouched.
|
|
94
97
|
- **Status slugs** come from `clokio_list_task_statuses` /
|
|
95
98
|
`clokio_get_project_statuses`.
|
|
99
|
+
- **Recurring tasks are templates.** Pass `recurrence` on create/update
|
|
100
|
+
(`{frequency, every?, days_of_week?, day_of_month?, anchor?}`) and the task
|
|
101
|
+
becomes the series template; **moving it to a done status starts the
|
|
102
|
+
schedule**. A fresh copy (same content, assignees, labels, estimate) is
|
|
103
|
+
created early on each matching day, due that day, independent of earlier
|
|
104
|
+
copies. Pause/resume with `recurrence_active`, stop with `recurrence: null`;
|
|
105
|
+
`recurrence.armed` on a task says whether the series is live, a copy names
|
|
106
|
+
its template as `recurrence.spawned_from`, and `series_of` on the list tool
|
|
107
|
+
returns every occurrence of one series.
|
|
96
108
|
- An API key sees **public custom fields only**.
|
|
97
109
|
|
|
98
110
|
## Security
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → HTML for the text an agent writes INTO Clokio.
|
|
3
|
+
*
|
|
4
|
+
* Clokio stores task descriptions and comments as sanitized HTML (the web
|
|
5
|
+
* editor is TipTap). A model naturally writes Markdown, and the first task
|
|
6
|
+
* created through this server from Claude arrived as one flat paragraph with
|
|
7
|
+
* literal `**` and backticks. Converting here keeps every tool's input
|
|
8
|
+
* natural for the model and correct for Clokio.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately small: paragraphs, line breaks, headings, bullet and numbered
|
|
11
|
+
* lists, fenced code, blockquotes, bold, italic, inline code and links. Text
|
|
12
|
+
* that already starts with an HTML tag is passed through untouched, so a
|
|
13
|
+
* caller that sends HTML keeps working. Everything else is escaped, so a
|
|
14
|
+
* stray `<b>` in prose shows as text rather than becoming markup.
|
|
15
|
+
*/
|
|
16
|
+
// `"` too, not just the tag characters: escaping runs BEFORE the link regex,
|
|
17
|
+
// and the generated <a href="..."> wraps the URL in double quotes - a
|
|
18
|
+
// quote the URL smuggles through would close the attribute and open a new
|
|
19
|
+
// one (`[x](https://e.com/"onmouseover="alert(1))` produced a live
|
|
20
|
+
// onmouseover, reproduced in review). Clokio's server sanitizer strips such
|
|
21
|
+
// attributes anyway, but this package is consumed outside Clokio's web
|
|
22
|
+
// surfaces too, and it must not emit injectable HTML in the first place.
|
|
23
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
24
|
+
// The EXACT shape the backend's upload answers with (tasks/org-{n}/{n}/media/
|
|
25
|
+
// {file}) - anchored, no slash or dot-segment room. A bare startsWith
|
|
26
|
+
// allowlist let `/tasks/media/../../anything` through, and the browser
|
|
27
|
+
// normalizes dot segments BEFORE sending, so that <img> fired an arbitrary
|
|
28
|
+
// same-origin GET with the logged-in reader's session cookie (caught in
|
|
29
|
+
// review). Anything under /tasks/media/ that is not this shape stays text.
|
|
30
|
+
const MEDIA_PATH = /^\/tasks\/media\/tasks\/org-\d+\/\d+\/media\/[^/\s)]+$/;
|
|
31
|
+
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
32
|
+
function inline(text) {
|
|
33
|
+
// The stash protects finished HTML from the LATER rules: inline code first
|
|
34
|
+
// (so its contents are never styled or linked), then each generated
|
|
35
|
+
// image/video/anchor tag - without that, the em rule rewrote a `*` inside
|
|
36
|
+
// a URL into <em> IN THE src ATTRIBUTE and broke the image (caught in
|
|
37
|
+
// review; alt text likewise stays literal rather than styled).
|
|
38
|
+
const stashed = [];
|
|
39
|
+
const stash = (html) => {
|
|
40
|
+
stashed.push(html);
|
|
41
|
+
return `\u0000${stashed.length - 1}\u0000`;
|
|
42
|
+
};
|
|
43
|
+
let s = text.replace(/`([^`\n]+)`/g, (_m, code) => stash(`<code>${escapeHtml(code)}</code>`));
|
|
44
|
+
s = escapeHtml(s);
|
|
45
|
+
// Images BEFORE links, or the link rule eats the [alt](url) part and leaves
|
|
46
|
+
// a stray "!". Besides https, the /tasks/media/ urls that
|
|
47
|
+
// clokio_upload_task_media returns are allowed - embedding them is that
|
|
48
|
+
// tool's whole point (task #5296). A video extension gets a <video> tag:
|
|
49
|
+
// an <img> pointing at an mp4 renders as a broken image. escapeHtml
|
|
50
|
+
// already ran, so a quote in the URL is " and cannot close the
|
|
51
|
+
// attribute.
|
|
52
|
+
s = s.replace(/!\[([^\]\n]*)\]\(((?:https?:\/\/|\/tasks\/media\/)[^)\s]+)\)/g, (whole, alt, url) => {
|
|
53
|
+
if (url.startsWith('/tasks/media/') && !MEDIA_PATH.test(url))
|
|
54
|
+
return whole;
|
|
55
|
+
return stash(VIDEO_EXT.test(url)
|
|
56
|
+
? `<video src="${url}" controls></video>`
|
|
57
|
+
: `<img src="${url}" alt="${alt}">`);
|
|
58
|
+
});
|
|
59
|
+
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_m, label, url) => stash(`<a href="${url}">${label}</a>`));
|
|
60
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
61
|
+
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, '$1<em>$2</em>');
|
|
62
|
+
return s.replace(/\u0000(\d+)\u0000/g, (_m, i) => stashed[Number(i)]);
|
|
63
|
+
}
|
|
64
|
+
export function looksLikeHtml(text) {
|
|
65
|
+
// `video` is in the list for the same reason `img` is: the media tool's
|
|
66
|
+
// HTML advice starts a body with exactly that tag, and leaving it out
|
|
67
|
+
// escaped the whole body to literal text (caught in review).
|
|
68
|
+
return /^\s*<(p|div|ul|ol|li|h[1-6]|strong|em|b|i|u|s|br|pre|code|blockquote|a|img|video|span|table)\b[^>]*>/i.test(text);
|
|
69
|
+
}
|
|
70
|
+
export function markdownToHtml(input) {
|
|
71
|
+
if (input == null)
|
|
72
|
+
return '';
|
|
73
|
+
const text = String(input);
|
|
74
|
+
if (!text.trim())
|
|
75
|
+
return '';
|
|
76
|
+
if (looksLikeHtml(text))
|
|
77
|
+
return text;
|
|
78
|
+
const out = [];
|
|
79
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
80
|
+
let para = [];
|
|
81
|
+
let list = null;
|
|
82
|
+
let quote = [];
|
|
83
|
+
const flushPara = () => {
|
|
84
|
+
if (para.length)
|
|
85
|
+
out.push(`<p>${para.map(inline).join('<br>')}</p>`);
|
|
86
|
+
para = [];
|
|
87
|
+
};
|
|
88
|
+
const flushList = () => {
|
|
89
|
+
if (list)
|
|
90
|
+
out.push(`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.tag}>`);
|
|
91
|
+
list = null;
|
|
92
|
+
};
|
|
93
|
+
const flushQuote = () => {
|
|
94
|
+
if (quote.length)
|
|
95
|
+
out.push(`<blockquote><p>${quote.map(inline).join('<br>')}</p></blockquote>`);
|
|
96
|
+
quote = [];
|
|
97
|
+
};
|
|
98
|
+
const flushAll = () => {
|
|
99
|
+
flushPara();
|
|
100
|
+
flushList();
|
|
101
|
+
flushQuote();
|
|
102
|
+
};
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (/^```/.test(trimmed)) {
|
|
107
|
+
flushAll();
|
|
108
|
+
const code = [];
|
|
109
|
+
i++;
|
|
110
|
+
while (i < lines.length && !/^```/.test(lines[i].trim()))
|
|
111
|
+
code.push(lines[i++]);
|
|
112
|
+
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!trimmed) {
|
|
116
|
+
flushAll();
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
|
120
|
+
if (heading) {
|
|
121
|
+
flushAll();
|
|
122
|
+
out.push(`<h${heading[1].length}>${inline(heading[2])}</h${heading[1].length}>`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const quoted = /^>\s?(.*)$/.exec(trimmed);
|
|
126
|
+
if (quoted) {
|
|
127
|
+
flushPara();
|
|
128
|
+
flushList();
|
|
129
|
+
quote.push(quoted[1]);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const bullet = /^[-*+]\s+(.+)$/.exec(trimmed);
|
|
133
|
+
const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
|
134
|
+
if (bullet || numbered) {
|
|
135
|
+
flushPara();
|
|
136
|
+
flushQuote();
|
|
137
|
+
const tag = bullet ? 'ul' : 'ol';
|
|
138
|
+
if (!list || list.tag !== tag) {
|
|
139
|
+
flushList();
|
|
140
|
+
list = { tag, items: [] };
|
|
141
|
+
}
|
|
142
|
+
list.items.push((bullet ?? numbered)[1]);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
flushList();
|
|
146
|
+
flushQuote();
|
|
147
|
+
para.push(trimmed);
|
|
148
|
+
}
|
|
149
|
+
flushAll();
|
|
150
|
+
return out.join('');
|
|
151
|
+
}
|
package/dist/tools/tasks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { markdownToHtml } from '../markdown.js';
|
|
2
3
|
import { request, requestUpload } from '../client.js';
|
|
3
4
|
import { registerTool } from './helpers.js';
|
|
4
5
|
import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLatest, } from '../attachments.js';
|
|
@@ -6,6 +7,122 @@ import { asAttachmentArray, downloadAttachment, safeAttachmentName, stampIsLates
|
|
|
6
7
|
const employeeCode = z
|
|
7
8
|
.string()
|
|
8
9
|
.regex(/^[A-Za-z0-9_-]{1,50}$/, 'employee_code must be 1-50 chars of letters, digits, _ or -');
|
|
10
|
+
/**
|
|
11
|
+
* Recurrence, as a structured object the agent fills, serialized here into
|
|
12
|
+
* the rule string the Clokio scheduler executes (`daily`, `weekly:sun,wed`,
|
|
13
|
+
* `monthly:15`, `quarterly:MM-DD`, `custom:N:unit[:detail]`, ...).
|
|
14
|
+
*
|
|
15
|
+
* The server model is a TEMPLATE: the task itself is the series. Once the
|
|
16
|
+
* template sits in a done status (completing it ARMS the series), the
|
|
17
|
+
* scheduler creates a fresh copy - same title, description, assignees,
|
|
18
|
+
* labels, priority, estimate - early on each matching day, due that day,
|
|
19
|
+
* regardless of whether earlier copies were closed. The template stays done
|
|
20
|
+
* forever; the copies are the actual work items.
|
|
21
|
+
*/
|
|
22
|
+
const recurrenceInput = z.object({
|
|
23
|
+
frequency: z.enum(['daily', 'weekly', 'monthly', 'quarterly', 'semiannual', 'yearly']),
|
|
24
|
+
every: z
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(999)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Every N periods (default 1). "Every 2 weeks" = frequency weekly + every 2.'),
|
|
31
|
+
days_of_week: z
|
|
32
|
+
.array(z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']))
|
|
33
|
+
.min(1)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('weekly: which weekday(s). REQUIRED for weekly.'),
|
|
36
|
+
day_of_month: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(31)
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('monthly: which day of the month. REQUIRED for monthly.'),
|
|
43
|
+
anchor: z
|
|
44
|
+
.string()
|
|
45
|
+
.regex(/^\d{2}-\d{2}$/, 'anchor must be MM-DD')
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('quarterly / semiannual / yearly: the MM-DD date the cycle starts from. REQUIRED for those.'),
|
|
48
|
+
});
|
|
49
|
+
/** Canonical weekday order for serialized rules - see recurrenceToRule(). */
|
|
50
|
+
const WEEKDAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
51
|
+
/**
|
|
52
|
+
* A field that names a day the chosen frequency does not use is REFUSED, not
|
|
53
|
+
* ignored: {frequency: 'daily', days_of_week: ['mon']} reads like "every
|
|
54
|
+
* Monday", and silently dropping the days spawns SEVEN copies a week.
|
|
55
|
+
*/
|
|
56
|
+
function refuseIrrelevant(r, allowed) {
|
|
57
|
+
for (const field of ['days_of_week', 'day_of_month', 'anchor']) {
|
|
58
|
+
if (r[field] !== undefined && !allowed.includes(field)) {
|
|
59
|
+
throw new Error(`${field} does not apply to frequency "${r.frequency}" and would be silently ignored - remove it, or pick the frequency it belongs to.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Serialize the structured object into the scheduler's rule grammar. */
|
|
64
|
+
export function recurrenceToRule(r) {
|
|
65
|
+
const every = r.every ?? 1;
|
|
66
|
+
switch (r.frequency) {
|
|
67
|
+
case 'daily':
|
|
68
|
+
refuseIrrelevant(r, []);
|
|
69
|
+
return every === 1 ? 'daily' : `custom:${every}:day`;
|
|
70
|
+
case 'weekly': {
|
|
71
|
+
refuseIrrelevant(r, ['days_of_week']);
|
|
72
|
+
if (!r.days_of_week?.length) {
|
|
73
|
+
// A day-less weekly is the exact rule shape behind a production
|
|
74
|
+
// outage - the server refuses it too, but failing here names the fix.
|
|
75
|
+
throw new Error('weekly recurrence requires days_of_week (e.g. ["sun"]).');
|
|
76
|
+
}
|
|
77
|
+
// Canonical: dedupe + fixed weekday order, so the same intent always
|
|
78
|
+
// stores the same rule string ("did the rule change?" comparisons and
|
|
79
|
+
// health reports read these back).
|
|
80
|
+
const unique = [...new Set(r.days_of_week)];
|
|
81
|
+
const days = WEEKDAY_ORDER.filter((d) => unique.includes(d)).join(',');
|
|
82
|
+
if (every > 1 && unique.length > 1) {
|
|
83
|
+
// The scheduler's custom:N:week rule spawns ONE occurrence per cycle
|
|
84
|
+
// (the day list only seeds which day), so "every 2 weeks on Mon and
|
|
85
|
+
// Thu" would silently become "every 2 weeks on Mon". Refuse rather
|
|
86
|
+
// than surprise - weekly (every: 1) does cover multiple days. The
|
|
87
|
+
// server refuses the same shape (ValidRecurrenceRule).
|
|
88
|
+
throw new Error('every > 1 with multiple days_of_week is not supported by the scheduler (it would spawn only on the earliest day). Use one day, or every: 1 for all of them.');
|
|
89
|
+
}
|
|
90
|
+
return every === 1 ? `weekly:${days}` : `custom:${every}:week:${days}`;
|
|
91
|
+
}
|
|
92
|
+
case 'monthly': {
|
|
93
|
+
refuseIrrelevant(r, ['day_of_month']);
|
|
94
|
+
if (!r.day_of_month) {
|
|
95
|
+
throw new Error('monthly recurrence requires day_of_month (1-31).');
|
|
96
|
+
}
|
|
97
|
+
return every === 1 ? `monthly:${r.day_of_month}` : `custom:${every}:month:${r.day_of_month}`;
|
|
98
|
+
}
|
|
99
|
+
case 'quarterly':
|
|
100
|
+
case 'semiannual':
|
|
101
|
+
case 'yearly': {
|
|
102
|
+
refuseIrrelevant(r, ['anchor']);
|
|
103
|
+
if (!r.anchor) {
|
|
104
|
+
throw new Error(`${r.frequency} recurrence requires anchor (MM-DD, the cycle's first date).`);
|
|
105
|
+
}
|
|
106
|
+
// The MM-DD shape is schema-checked; the RANGE is checked here so
|
|
107
|
+
// "13-45" fails fast with the field named, not as a generic server 422.
|
|
108
|
+
const [mo, dy] = r.anchor.split('-').map(Number);
|
|
109
|
+
if (mo < 1 || mo > 12 || dy < 1 || dy > 31) {
|
|
110
|
+
throw new Error(`anchor "${r.anchor}" is not a real date - MM must be 01-12 and DD 01-31.`);
|
|
111
|
+
}
|
|
112
|
+
if (every !== 1) {
|
|
113
|
+
throw new Error(`${r.frequency} does not take "every" - its cadence is fixed. For other cadences use frequency monthly/weekly with every.`);
|
|
114
|
+
}
|
|
115
|
+
return `${r.frequency}:${r.anchor}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** The one description of the template model, shared by both task tools. */
|
|
120
|
+
const RECURRENCE_MODEL_NOTE = 'RECURRING TASKS: pass `recurrence` to make this task a repeating series. The task becomes the series ' +
|
|
121
|
+
'TEMPLATE - move it to a done status to START the schedule (an open template is configured but not ' +
|
|
122
|
+
'spawning; the resource reports this as recurrence.armed). The scheduler then creates a fresh copy of ' +
|
|
123
|
+
'the template (title, description, assignees, labels, priority, estimate) early on each matching day, ' +
|
|
124
|
+
'due that day, independent of whether earlier copies were closed. A multi-period cadence ' +
|
|
125
|
+
'(every: N > 1) counts whole periods from the task\'s DUE DATE, so set one.';
|
|
9
126
|
/** Task-management tools: the core of the Clokio API surface. */
|
|
10
127
|
export function registerTaskTools(server, config) {
|
|
11
128
|
registerTool(server, config, {
|
|
@@ -23,6 +140,17 @@ export function registerTaskTools(server, config) {
|
|
|
23
140
|
label: z.string().optional(),
|
|
24
141
|
search: z.string().optional().describe('Free text over title and description'),
|
|
25
142
|
open: z.boolean().optional().describe('true = only tasks whose status is not a "done" one'),
|
|
143
|
+
recurring: z
|
|
144
|
+
.boolean()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe('true = only the recurring SERIES TEMPLATES (tasks carrying a recurrence rule); false = only ' +
|
|
147
|
+
'ordinary tasks, which includes the copies a template spawns. Omit for no filter.'),
|
|
148
|
+
series_of: z
|
|
149
|
+
.number()
|
|
150
|
+
.int()
|
|
151
|
+
.optional()
|
|
152
|
+
.describe('A series template\'s task id: returns every occurrence that template spawned ("did the Sunday ' +
|
|
153
|
+
'check actually happen every week?"). A copy also names its template as recurrence.spawned_from.'),
|
|
26
154
|
parent_task_id: z
|
|
27
155
|
.number()
|
|
28
156
|
.int()
|
|
@@ -78,6 +206,8 @@ export function registerTaskTools(server, config) {
|
|
|
78
206
|
label: args.label,
|
|
79
207
|
search: args.search,
|
|
80
208
|
open: args.open === undefined ? undefined : args.open ? 1 : 0,
|
|
209
|
+
recurring: args.recurring === undefined ? undefined : args.recurring ? 1 : 0,
|
|
210
|
+
series_of: args.series_of,
|
|
81
211
|
parent_task_id: args.parent_task_id === null ? '' : args.parent_task_id,
|
|
82
212
|
created_before: args.created_before,
|
|
83
213
|
created_after: args.created_after,
|
|
@@ -117,11 +247,12 @@ export function registerTaskTools(server, config) {
|
|
|
117
247
|
'You do NOT normally need created_by_employee_code: an unnamed task is attributed to the key\'s issuer ' +
|
|
118
248
|
'automatically (whoami shows who that is). Set it only to credit someone OTHER than the key owner. ' +
|
|
119
249
|
'Pass parent_task_id to create it as a SUBTASK of that task ' +
|
|
120
|
-
'(same project, and the parent must not itself be a subtask - one level deep only)
|
|
250
|
+
'(same project, and the parent must not itself be a subtask - one level deep only).\n\n' +
|
|
251
|
+
RECURRENCE_MODEL_NOTE,
|
|
121
252
|
schema: {
|
|
122
253
|
project_id: z.number().int(),
|
|
123
254
|
title: z.string().max(255),
|
|
124
|
-
description: z.string().optional().describe('HTML or plain text'),
|
|
255
|
+
description: z.string().optional().describe('Markdown, HTML or plain text (Markdown is converted to HTML)'),
|
|
125
256
|
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
|
126
257
|
status: z.string().optional().describe("Status slug valid for THIS project's board"),
|
|
127
258
|
due_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
@@ -135,31 +266,61 @@ export function registerTaskTools(server, config) {
|
|
|
135
266
|
.optional()
|
|
136
267
|
.describe('Label names. A name that does not exist yet is CREATED - check clokio_list_task_labels first'),
|
|
137
268
|
created_by_employee_code: employeeCode.optional().describe('Credit someone OTHER than the key owner; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
|
|
269
|
+
recurrence: recurrenceInput
|
|
270
|
+
.optional()
|
|
271
|
+
.describe('Make this task a recurring series (see the tool description for how the template model works)'),
|
|
272
|
+
},
|
|
273
|
+
handler: async (args, cfg) => {
|
|
274
|
+
const result = (await request(cfg, '/tasks', {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
body: {
|
|
277
|
+
project_id: args.project_id,
|
|
278
|
+
title: args.title,
|
|
279
|
+
description: args.description === undefined ? undefined : markdownToHtml(args.description),
|
|
280
|
+
priority: args.priority,
|
|
281
|
+
status: args.status,
|
|
282
|
+
due_date: args.due_date,
|
|
283
|
+
start_date: args.start_date,
|
|
284
|
+
estimated_hours: args.estimated_hours,
|
|
285
|
+
parent_task_id: args.parent_task_id,
|
|
286
|
+
assignee_employee_codes: args.assignee_employee_codes,
|
|
287
|
+
label_names: args.label_names,
|
|
288
|
+
created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
|
|
289
|
+
...(args.recurrence
|
|
290
|
+
? { is_recurring: true, recurrence_rule: recurrenceToRule(args.recurrence) }
|
|
291
|
+
: {}),
|
|
292
|
+
},
|
|
293
|
+
}));
|
|
294
|
+
// A Clokio older than the recurrence fields IGNORES unknown body keys
|
|
295
|
+
// on create (Laravel validate() drops what it has no rule for), so the
|
|
296
|
+
// task would be born NON-recurring with a 201 - and the agent would
|
|
297
|
+
// report a weekly series that never spawns. The echoed resource is the
|
|
298
|
+
// truth: if we asked for recurrence and it is not reflected, say so
|
|
299
|
+
// loudly instead of letting the silence stand.
|
|
300
|
+
if (args.recurrence && result && typeof result === 'object') {
|
|
301
|
+
const data = result.data;
|
|
302
|
+
const echoed = data?.recurrence;
|
|
303
|
+
if (echoed?.is_recurring !== true) {
|
|
304
|
+
result.warning =
|
|
305
|
+
'The task WAS created, but the server IGNORED the recurrence fields - this Clokio version ' +
|
|
306
|
+
'predates recurring-series support on the API. The task will NOT repeat. Update the server, ' +
|
|
307
|
+
'then set the recurrence again with clokio_update_task.';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
138
311
|
},
|
|
139
|
-
handler: (args, cfg) => request(cfg, '/tasks', {
|
|
140
|
-
method: 'POST',
|
|
141
|
-
body: {
|
|
142
|
-
project_id: args.project_id,
|
|
143
|
-
title: args.title,
|
|
144
|
-
description: args.description,
|
|
145
|
-
priority: args.priority,
|
|
146
|
-
status: args.status,
|
|
147
|
-
due_date: args.due_date,
|
|
148
|
-
start_date: args.start_date,
|
|
149
|
-
estimated_hours: args.estimated_hours,
|
|
150
|
-
parent_task_id: args.parent_task_id,
|
|
151
|
-
assignee_employee_codes: args.assignee_employee_codes,
|
|
152
|
-
label_names: args.label_names,
|
|
153
|
-
created_by_employee_code: args.created_by_employee_code ?? cfg.defaultActor,
|
|
154
|
-
},
|
|
155
|
-
}),
|
|
156
312
|
});
|
|
157
313
|
registerTool(server, config, {
|
|
158
314
|
name: 'clokio_update_task',
|
|
159
315
|
mutates: true,
|
|
160
316
|
description: 'Update a task. ONLY the fields you pass are changed; omitted fields are left alone. Pass null to ' +
|
|
161
317
|
'due_date / start_date / estimated_hours / description to CLEAR them. Set actor_employee_code so the ' +
|
|
162
|
-
'activity log names the person rather than the API key
|
|
318
|
+
'activity log names the person rather than the API key.\n\n' +
|
|
319
|
+
RECURRENCE_MODEL_NOTE +
|
|
320
|
+
'\n\nManage an existing series here: `recurrence` (an object) sets or retunes the rule and switches ' +
|
|
321
|
+
'the series ON; `recurrence: null` STOPS it (future copies stop; already-spawned copies are ordinary ' +
|
|
322
|
+
'tasks and stay). To PAUSE while keeping the rule stored, pass `recurrence_active: false`, and ' +
|
|
323
|
+
'`recurrence_active: true` to resume.',
|
|
163
324
|
schema: {
|
|
164
325
|
id: z.number().int(),
|
|
165
326
|
title: z.string().max(255).optional(),
|
|
@@ -171,6 +332,14 @@ export function registerTaskTools(server, config) {
|
|
|
171
332
|
estimated_hours: z.number().min(0).max(9999.99).nullable().optional(),
|
|
172
333
|
project_id: z.number().int().optional().describe('Move the task to another project'),
|
|
173
334
|
actor_employee_code: employeeCode.optional().describe('Who to credit in the activity log; omit to use the key issuer / CLOKIO_DEFAULT_ACTOR'),
|
|
335
|
+
recurrence: recurrenceInput
|
|
336
|
+
.nullable()
|
|
337
|
+
.optional()
|
|
338
|
+
.describe('Set/retune the series rule (and switch it on), or null to STOP the series'),
|
|
339
|
+
recurrence_active: z
|
|
340
|
+
.boolean()
|
|
341
|
+
.optional()
|
|
342
|
+
.describe('Pause (false) / resume (true) the series without touching its stored rule'),
|
|
174
343
|
},
|
|
175
344
|
handler: (args, cfg) => {
|
|
176
345
|
// Only keys the caller actually supplied may go in the body: PATCH
|
|
@@ -191,6 +360,26 @@ export function registerTaskTools(server, config) {
|
|
|
191
360
|
if (args[key] !== undefined)
|
|
192
361
|
body[key] = args[key];
|
|
193
362
|
}
|
|
363
|
+
if (typeof body.description === 'string')
|
|
364
|
+
body.description = markdownToHtml(body.description);
|
|
365
|
+
// Series management maps onto the API's two columns. `recurrence`
|
|
366
|
+
// carries the whole intent (rule + on/off), so combining it with the
|
|
367
|
+
// bare pause/resume switch is ambiguous and refused rather than
|
|
368
|
+
// silently picking a winner.
|
|
369
|
+
if (args.recurrence !== undefined && args.recurrence_active !== undefined) {
|
|
370
|
+
throw new Error('Pass either recurrence or recurrence_active, not both.');
|
|
371
|
+
}
|
|
372
|
+
if (args.recurrence === null) {
|
|
373
|
+
body.is_recurring = false;
|
|
374
|
+
body.recurrence_rule = null;
|
|
375
|
+
}
|
|
376
|
+
else if (args.recurrence !== undefined) {
|
|
377
|
+
body.is_recurring = true;
|
|
378
|
+
body.recurrence_rule = recurrenceToRule(args.recurrence);
|
|
379
|
+
}
|
|
380
|
+
else if (args.recurrence_active !== undefined) {
|
|
381
|
+
body.is_recurring = args.recurrence_active;
|
|
382
|
+
}
|
|
194
383
|
// Credit the update to CLOKIO_DEFAULT_ACTOR when the caller named no
|
|
195
384
|
// actor. The API otherwise falls back to the key's issuer on its own,
|
|
196
385
|
// so this only overrides for a shared/service key. An explicit
|
|
@@ -234,7 +423,7 @@ export function registerTaskTools(server, config) {
|
|
|
234
423
|
mutates: true,
|
|
235
424
|
description: 'Add a comment to a task. author_employee_code is usually unnecessary: an unnamed comment is attributed ' +
|
|
236
425
|
'to the key issuer automatically. Set it only to credit someone else. Use readable formatting ' +
|
|
237
|
-
'(paragraphs, blank lines, bullet lists);
|
|
426
|
+
'(paragraphs, blank lines, bullet lists, **bold**, `code`); Markdown is converted to HTML, but TABLES are not rendered and post as raw text. Write ' +
|
|
238
427
|
'in the language of the person you are answering. ' +
|
|
239
428
|
'TO MENTION SOMEONE, write @ followed by their full name exactly as clokio_list_employees spells it ' +
|
|
240
429
|
'(for example "@Kiran Bahadur") in the body text - no markup needed. It becomes a real mention and ' +
|
|
@@ -250,7 +439,7 @@ export function registerTaskTools(server, config) {
|
|
|
250
439
|
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments`, {
|
|
251
440
|
method: 'POST',
|
|
252
441
|
body: {
|
|
253
|
-
body: args.body,
|
|
442
|
+
body: markdownToHtml(args.body),
|
|
254
443
|
author_employee_code: args.author_employee_code ?? cfg.defaultActor,
|
|
255
444
|
notify: args.notify,
|
|
256
445
|
},
|
|
@@ -272,7 +461,7 @@ export function registerTaskTools(server, config) {
|
|
|
272
461
|
},
|
|
273
462
|
handler: (args, cfg) => request(cfg, `/tasks/${args.id}/comments/${args.comment_id}`, {
|
|
274
463
|
method: 'PATCH',
|
|
275
|
-
body: { body: args.body, author_employee_code: args.author_employee_code ?? cfg.defaultActor },
|
|
464
|
+
body: { body: markdownToHtml(args.body), author_employee_code: args.author_employee_code ?? cfg.defaultActor },
|
|
276
465
|
}),
|
|
277
466
|
});
|
|
278
467
|
registerTool(server, config, {
|
|
@@ -395,10 +584,12 @@ export function registerTaskTools(server, config) {
|
|
|
395
584
|
registerTool(server, config, {
|
|
396
585
|
name: 'clokio_upload_task_attachment',
|
|
397
586
|
mutates: true,
|
|
398
|
-
description: 'Upload a LOCAL FILE to a task as an attachment - a
|
|
587
|
+
description: 'Upload a LOCAL FILE to a task as an attachment - a log, a spreadsheet, a PDF. Give the path to ' +
|
|
399
588
|
'a file on this machine; it is read and sent. Allowed types include images, PDF, Office docs, txt/csv/' +
|
|
400
589
|
'md/json, code diffs, zip and video, up to 200 MB. The uploader is the key issuer unless you set ' +
|
|
401
|
-
'uploaded_by_employee_code.'
|
|
590
|
+
'uploaded_by_employee_code. NOTE: an attachment is a file in the task\'s file list - its download_path ' +
|
|
591
|
+
'only loads WITH an API key, so it can NOT be shown inline in a comment. For an image or video the ' +
|
|
592
|
+
'reader should SEE, use clokio_upload_task_media instead.',
|
|
402
593
|
schema: {
|
|
403
594
|
id: z.number().int().describe('The task id'),
|
|
404
595
|
file_path: z.string().describe('Path to a file on THIS machine'),
|
|
@@ -410,6 +601,21 @@ export function registerTaskTools(server, config) {
|
|
|
410
601
|
uploaded_by_employee_code: args.uploaded_by_employee_code ?? cfg.defaultActor,
|
|
411
602
|
}),
|
|
412
603
|
});
|
|
604
|
+
registerTool(server, config, {
|
|
605
|
+
name: 'clokio_upload_task_media',
|
|
606
|
+
mutates: true,
|
|
607
|
+
description: 'Upload an image or video the reader should SEE INLINE in a comment or the task description ' +
|
|
608
|
+
'(task #5296). Returns {url} - a /tasks/media/... path served to logged-in users with no API key. ' +
|
|
609
|
+
'Embed it in the body you then post: Markdown  or HTML <img src="URL">. ' +
|
|
610
|
+
'Media types only (jpg/png/gif/webp/heic/mp4/webm/mov), 50 MB; HEIC becomes JPEG. This does NOT add ' +
|
|
611
|
+
'the file to the attachments list - for a downloadable file (zip, PDF, log), use ' +
|
|
612
|
+
'clokio_upload_task_attachment instead.',
|
|
613
|
+
schema: {
|
|
614
|
+
id: z.number().int().describe('The task id'),
|
|
615
|
+
file_path: z.string().describe('Path to an image/video on THIS machine'),
|
|
616
|
+
},
|
|
617
|
+
handler: (args, cfg) => requestUpload(cfg, `/tasks/${args.id}/media`, args.file_path),
|
|
618
|
+
});
|
|
413
619
|
registerTool(server, config, {
|
|
414
620
|
name: 'clokio_get_task_activity',
|
|
415
621
|
description: 'Get the activity log (audit trail) for a task.',
|
package/package.json
CHANGED