taskswipe-mcp 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # taskswipe-mcp
2
2
 
3
- Your TASK//SWIPE day, from inside Claude Code, Claude Desktop, Codex or Cursor.
3
+ Your TASKKA day, from inside Claude Code, Claude Desktop, Codex or Cursor.
4
4
 
5
5
  ```
6
6
  you: add "call the bank" and "book the dentist" to today
@@ -25,17 +25,40 @@ row-level security still applies and no service key exists anywhere.
25
25
  the server reading credentials from your own machine rather than sending a
26
26
  token over the wire.
27
27
 
28
- ## Setup
28
+ ## One-time VPS setup (Hermes / OpenClaw)
29
29
 
30
- You need your TASK//SWIPE login token that's it. The production Supabase URL
31
- and anon key are baked in as defaults (they're public — they ship in the web
32
- app's JS bundle); self-hosters can override them with `TASKSWIPE_SUPABASE_URL`
33
- and `TASKSWIPE_SUPABASE_ANON_KEY`.
30
+ Run one command in an interactive SSH session as the same Linux user that runs
31
+ the agent:
34
32
 
35
- > **Not on npm yet.** Until `taskswipe-mcp` is published, every client below
36
- > runs it from this repo by path:
37
- > `node ~/Developer/swipe-todo-react/mcp/src/index.js`
38
- > Once it's published, replace that with `npx -y taskswipe-mcp` everywhere.
33
+ ```bash
34
+ npx -y taskswipe-mcp@latest setup hermes
35
+ ```
36
+
37
+ or:
38
+
39
+ ```bash
40
+ npx -y taskswipe-mcp@latest setup openclaw
41
+ ```
42
+
43
+ The installer asks for the TASKKA email and password. Password input is hidden;
44
+ the password is used once to mint a separate session for that VPS and is never
45
+ written to disk. The renewable session is saved at
46
+ `~/.taskswipe-mcp-session.json` with mode `0600`, then the installer registers
47
+ the stdio MCP, installs the TASKKA skill and runs the agent's connection check.
48
+ No tunnel, public port, YAML/JSON edit or hourly token copy is involved.
49
+
50
+ This is safer than copying the browser refresh token: the browser and VPS have
51
+ independent sessions and can rotate their credentials without racing each other.
52
+
53
+ ## Manual setup and other clients
54
+
55
+ The production Supabase URL and anon key are baked in as defaults (they're
56
+ public — they ship in the web app's JS bundle); self-hosters can override them
57
+ with `TASKSWIPE_SUPABASE_URL` and `TASKSWIPE_SUPABASE_ANON_KEY`.
58
+
59
+ > **Published:** https://www.npmjs.com/package/taskswipe-mcp — so anywhere the
60
+ > examples below run it by path, `npx -y taskswipe-mcp` works instead and needs
61
+ > nothing checked out.
39
62
 
40
63
  **Claude Code**
41
64
 
@@ -74,6 +97,46 @@ TASKSWIPE_ACCESS_TOKEN = "eyJ..."
74
97
  (`codex mcp list` to confirm it registered; note the section is
75
98
  `mcp_servers`, snake_case, not `mcpServers`.)
76
99
 
100
+ **Hermes Agent** (Nous) — prefer the one-command setup above. Its resulting
101
+ credential-free entry is equivalent to:
102
+
103
+ ```yaml
104
+ mcp_servers:
105
+ taskka:
106
+ command: "npx"
107
+ args: ["-y", "taskswipe-mcp"]
108
+ ```
109
+
110
+ ```bash
111
+ hermes skills install https://taskka.app/.well-known/skills/taskka/SKILL.md
112
+ ```
113
+
114
+ The server reads the renewable VPS session from the owner-only session file.
115
+
116
+ **OpenClaw** — prefer the one-command setup above. Its resulting entry is:
117
+
118
+ ```json
119
+ {
120
+ "mcp": {
121
+ "servers": {
122
+ "taskka": {
123
+ "command": "npx",
124
+ "args": ["-y", "taskswipe-mcp@latest"]
125
+ }
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ ```bash
132
+ openclaw skills install https://taskka.app/.well-known/skills/taskka/SKILL.md
133
+ openclaw mcp doctor taskka --probe
134
+ ```
135
+
136
+ Hosted HTTP (`https://taskka.app/mcp` + `Authorization: Bearer <access>`) works
137
+ for a Control-UI one-shot. Access tokens die in about an hour — always-on
138
+ Hermes/OpenClaw gateways should use the one-time stdio bootstrap above.
139
+
77
140
  **Claude Desktop / Cursor** — these do use the JSON shape
78
141
  (`claude_desktop_config.json` / `.cursor/mcp.json`):
79
142
 
@@ -95,7 +158,7 @@ TASKSWIPE_ACCESS_TOKEN = "eyJ..."
95
158
 
96
159
  ### Getting an access token
97
160
 
98
- Sign in to TASK//SWIPE in a browser, open the console, and run:
161
+ Sign in to TASKKA in a browser, open the console, and run:
99
162
 
100
163
  ```js
101
164
  JSON.parse(Object.entries(localStorage).find(([k]) => k.includes('auth-token'))[1]).access_token
@@ -103,9 +166,9 @@ JSON.parse(Object.entries(localStorage).find(([k]) => k.includes('auth-token'))[
103
166
 
104
167
  It expires on its own, which is the point — a leaked token stops working.
105
168
 
106
- ### Making it permanent
169
+ ### Legacy token-seeded sessions
107
170
 
108
- Add `TASKSWIPE_REFRESH_TOKEN` next to it — same console snippet, ending in
171
+ For older configs, add `TASKSWIPE_REFRESH_TOKEN` next to it — same console snippet, ending in
109
172
  `.refresh_token` instead of `.access_token`. With the pair in place the server
110
173
  renews itself and keeps the CURRENT session in `~/.taskswipe-mcp-session.json`
111
174
  (owner-only file). That file matters: Supabase rotates refresh tokens on every
@@ -113,9 +176,10 @@ use and treats reuse of an old one as theft, so the config values are only the
113
176
  seed — after first run the file is the live credential. Signing out of that
114
177
  session (or deleting the file AND pasting a fresh pair) is the reset path.
115
178
 
116
- ### Password fallback
179
+ ### Stored-password fallback (not recommended)
117
180
 
118
- `TASKSWIPE_EMAIL` + `TASKSWIPE_PASSWORD` also work, and the server prefers a
181
+ `TASKSWIPE_EMAIL` + `TASKSWIPE_PASSWORD` also work, but this is not what the
182
+ one-time installer does. The server prefers a
119
183
  token whenever both are present. Understand the trade before using it: MCP
120
184
  config files sit unencrypted in your home directory, are frequently committed
121
185
  by accident, and are read by every tool on the machine. A password there is a
@@ -129,9 +193,7 @@ The repo root carries `.codex-plugin/plugin.json` + `.mcp.json`, which makes
129
193
  the whole repo an installable Codex plugin. The path to being listed
130
194
  (as of 2026-08):
131
195
 
132
- 1. **Publish `taskswipe-mcp` to npm**the plugin manifest runs
133
- `npx -y taskswipe-mcp`, which is dead until the package exists.
134
- `cd mcp && npm publish --access public` under an npm account.
196
+ 1. ~~Publish to npm~~**done**. The one-time VPS installer ships in `taskswipe-mcp@0.2.0`.
135
197
  2. **Interim listing** — submit the GitHub repo URL to the community registry
136
198
  at codex-marketplace.com (automated review of the manifest).
137
199
  3. **Official directory** — OpenAI's self-serve publishing is "coming soon";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "taskswipe-mcp",
3
- "version": "0.1.0",
4
- "description": "Read and write your TASK//SWIPE day from Claude Code, Claude Desktop, Codex or Cursor.",
3
+ "version": "0.2.0",
4
+ "description": "Read and write your TASKKA day from Hermes, OpenClaw, Claude Code, Codex or Cursor.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "taskswipe-mcp": "src/index.js"
package/src/brief.js ADDED
@@ -0,0 +1,310 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // THE BRIEF — the whole system, in about a page.
3
+ //
4
+ // THE PROBLEM. An agent connecting to TASKKA could reach six tools covering
5
+ // three of twenty-five tabs, and had no way to find out what the other
6
+ // twenty-two even were. Answering "how am I doing?" meant guessing which tools
7
+ // existed, calling several, and stitching. Answering anything about food, lifts,
8
+ // money, habits, goals or study was impossible — not because the data was
9
+ // private but because nothing described it.
10
+ //
11
+ // The lazy fix is get_state. That hands over a few hundred KB of arrays so the
12
+ // model can count them itself: slow, expensive, and mostly a waste, because
13
+ // ninety-nine percent of that blob is history nobody asked about.
14
+ //
15
+ // ── WHAT THIS DOES INSTEAD ──────────────────────────────────────────────────
16
+ //
17
+ // SUMMARISE, NEVER SERIALISE. No array goes out whole. Everything is a count,
18
+ // a headline, or the two or three items that actually need a decision. The
19
+ // brief is bounded — roughly the same size for someone on day 900 as on day 3 —
20
+ // because size here should track how much is GOING ON, not how much has ever
21
+ // happened.
22
+ //
23
+ // SIGNALS, NOT DATA. "3 habits missed 4 days" is the sentence an agent can act
24
+ // on. The habits array is a puzzle it has to solve first, badly, every time.
25
+ //
26
+ // SAY WHAT EXISTS. `areas` lists every part of the app with a one-line state,
27
+ // including the empty ones — an agent cannot ask about a tab it has no idea is
28
+ // there, and "nothing logged" is a useful answer to "what should I do next?".
29
+ //
30
+ // LAYERED. The brief is the map. `detail(area)` is the drill-down, and the
31
+ // existing tools do the writing. One cheap call to orient, one targeted call
32
+ // for depth — instead of six calls and a guess.
33
+ //
34
+ // Pure and state-in/JSON-out on purpose, so it can be tested without a network.
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ const arr = (v) => (Array.isArray(v) ? v : [])
38
+ const num = (v) => (Number.isFinite(+v) ? +v : 0)
39
+
40
+ const dayStr = (d = new Date()) =>
41
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
42
+
43
+ const daysAgo = (n, from = new Date()) => {
44
+ const d = new Date(from)
45
+ d.setDate(d.getDate() - n)
46
+ return dayStr(d)
47
+ }
48
+
49
+ const lastN = (n, from = new Date()) => Array.from({ length: n }, (_, i) => daysAgo(i, from))
50
+
51
+ /** A completion is 'YYYY-MM-DD' or {date, kind}; a MISSED day is not a doing. */
52
+ const stackDoneDays = (s) => new Set(arr(s?.completions)
53
+ .filter(c => (typeof c === 'string' ? true : c?.kind !== 'missed'))
54
+ .map(c => (typeof c === 'string' ? c : c?.date))
55
+ .filter(Boolean))
56
+
57
+ const habitDoneDays = (h) => new Set(arr(h?.completions).map(c => (typeof c === 'string' ? c : c?.date)).filter(Boolean))
58
+
59
+ /** How many of the last `win` days had a tick — the honest read of a streak. */
60
+ const hitRate = (days, win, from) => lastN(win, from).reduce((n, d) => n + (days.has(d) ? 1 : 0), 0)
61
+
62
+ const partOfDay = (h) => (h < 5 ? 'night' : h < 12 ? 'morning' : h < 17 ? 'afternoon' : h < 22 ? 'evening' : 'night')
63
+
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+
66
+ export function buildBrief(state = {}, now = new Date()) {
67
+ const today = dayStr(now)
68
+ const S = state || {}
69
+
70
+ // ── TODAY ─────────────────────────────────────────────────────────────────
71
+ const tasks = arr(S.todayTasks)
72
+ const open = tasks.filter(t => !t.done)
73
+ const done = tasks.filter(t => t.done)
74
+ const timed = open.filter(t => t.at).sort((a, b) => String(a.at).localeCompare(String(b.at)))
75
+ const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
76
+
77
+ // ── ATTENTION: the things a person would want raised ──────────────────────
78
+ // Ordered by severity so an agent can take the top one and be right most of
79
+ // the time. This is the part worth reading if it reads nothing else.
80
+ const attention = []
81
+
82
+ const overdue = timed.filter(t => String(t.at) < hhmm)
83
+ if (overdue.length) {
84
+ attention.push({ area: 'today', severity: 3,
85
+ what: `${overdue.length} scheduled ${overdue.length === 1 ? 'task is' : 'tasks are'} past their time`,
86
+ items: overdue.slice(0, 3).map(t => `${t.at} ${t.text}`) })
87
+ }
88
+
89
+ const missed = arr(S.missedTasks)
90
+ if (missed.length >= 3) {
91
+ attention.push({ area: 'today', severity: 1,
92
+ what: `${missed.length} tasks aged out unfinished and are sitting in DROPPED` })
93
+ }
94
+
95
+ // Habits are the app's committed tier: quiet ones are the real signal.
96
+ const quietHabits = arr(S.habits)
97
+ .map(h => ({ name: h.name || h.title, hits: hitRate(habitDoneDays(h), 7, now) }))
98
+ .filter(h => h.hits === 0)
99
+ if (quietHabits.length) {
100
+ attention.push({ area: 'habits', severity: 2,
101
+ what: `${quietHabits.length} habit${quietHabits.length === 1 ? '' : 's'} untouched for a week`,
102
+ items: quietHabits.slice(0, 4).map(h => h.name) })
103
+ }
104
+
105
+ const decay = S.decayState || {}
106
+ if (num(decay.quietStreak) >= 2) {
107
+ attention.push({ area: 'squares', severity: 2,
108
+ what: `${decay.quietStreak} quiet days in a row — squares are decaying` })
109
+ }
110
+
111
+ if (!S.coreTask && open.length > 3) {
112
+ attention.push({ area: 'today', severity: 1,
113
+ what: `${open.length} things open and nothing named as the one that matters` })
114
+ }
115
+
116
+ attention.sort((a, b) => b.severity - a.severity)
117
+
118
+ // ── AREAS: one line each, including the empty ones ────────────────────────
119
+ const foodToday = arr(S.foodLog?.[today])
120
+ const kcal = Math.round(foodToday.reduce((n, e) =>
121
+ n + (num(e.per100?.kcal) * num(e.grams)) / 100, 0))
122
+ const health = S.healthDaily?.[today] || {}
123
+
124
+ const stacks = arr(S.stacks).map(s => ({
125
+ name: s.name,
126
+ hits: hitRate(stackDoneDays(s), Math.round((num(s.graduationTarget) || 14) * 1.5), now),
127
+ target: num(s.graduationTarget) || 14,
128
+ }))
129
+ const readyStacks = stacks.filter(s => s.hits >= s.target)
130
+
131
+ const goalFrames = S.goals && typeof S.goals === 'object' ? Object.values(S.goals).flat() : []
132
+ const building = goalFrames.find(g => g?.id === S.activeGoalId)
133
+
134
+ const maps = arr(S.studyMaps)
135
+ const learningTopics = maps.flatMap(m => arr(m.nodes).filter(n => n.status === 'learning')
136
+ .map(n => `${m.title}: ${n.title}`))
137
+
138
+ const areas = {
139
+ today: `${open.length} open, ${done.length} done${S.coreTask ? `, core: ${S.coreTask.task || S.coreTask}` : ''}`,
140
+ backlog: `${arr(S.backlogTasks).length} parked`,
141
+ deck: `${arr(S.deck).length} cards waiting to be swiped`,
142
+ recurring: `${arr(S.recurringTasks).length} rules`,
143
+ missions: `${arr(S.missions).filter(m => !m.done).length} open`,
144
+ goals: goalFrames.length
145
+ ? `${goalFrames.length} goals${building ? `, building: ${building.text}` : ', none on the crane'}`
146
+ : 'none set',
147
+ habits: arr(S.habits).length
148
+ ? `${arr(S.habits).length} habits, ${arr(S.habits).filter(h => habitDoneDays(h).has(today)).length} ticked today`
149
+ : 'none',
150
+ stacks: stacks.length
151
+ ? `${stacks.length} building${readyStacks.length ? `, ${readyStacks.length} ready to graduate` : ''}`
152
+ : 'none',
153
+ food: foodToday.length
154
+ ? `${kcal} kcal logged today, ${arr(S.pantry).length} foods on the shelf`
155
+ : `nothing logged today, ${arr(S.pantry).length} foods on the shelf`,
156
+ lift: `${arr(S.liftSessions).length} sessions logged`,
157
+ walk: health.steps ? `${health.steps} steps today` : 'no step data today',
158
+ money: arr(S.moneyTx).length ? `${arr(S.moneyTx).length} transactions` : 'nothing tracked',
159
+ mind: S.moods?.[today]?.length
160
+ ? `${S.moods[today].length} check-ins today`
161
+ : 'no check-in today',
162
+ day_score: S.dayScores?.[today] ? `${S.dayScores[today].score}/10 posted` : 'not posted',
163
+ study: maps.length
164
+ ? `${maps.length} maps, ${learningTopics.length} topics in progress`
165
+ : 'none',
166
+ reader: `${arr(S.books).length} books`,
167
+ parley: `${arr(S.parleyDeck).length} cards`,
168
+ squares: `${num(S.totalSquares)} total${num(decay.quietStreak) ? `, ${decay.quietStreak} quiet days` : ''}`,
169
+ }
170
+
171
+ return {
172
+ now: { date: today, time: hhmm, part_of_day: partOfDay(now.getHours()) },
173
+
174
+ // The one sentence an agent should lead with. Everything below is evidence.
175
+ headline: attention[0]?.what
176
+ || (open.length ? `${open.length} open today, nothing overdue` : 'nothing open today'),
177
+
178
+ today: {
179
+ core_task: S.coreTask?.task || S.coreTask || null,
180
+ open: open.length,
181
+ done: done.length,
182
+ // Only the ones with a time — an untimed list of forty is not a schedule.
183
+ next: timed.filter(t => String(t.at) >= hhmm).slice(0, 3).map(t => `${t.at} ${t.text}`),
184
+ overdue: overdue.slice(0, 5).map(t => `${t.at} ${t.text}`),
185
+ open_titles: open.slice(0, 12).map(t => t.text),
186
+ },
187
+
188
+ attention: attention.slice(0, 6),
189
+ areas,
190
+
191
+ // Where to look next, so an agent does not have to guess which tools exist.
192
+ drill: {
193
+ more_on_an_area: "detail(area) — any key from `areas`",
194
+ today_in_full: 'today()',
195
+ find_anything: 'search(query)',
196
+ write: 'add_tasks, complete_task, capture, set_core_task',
197
+ },
198
+ }
199
+ }
200
+
201
+ // ─────────────────────────────────────────────────────────────────────────────
202
+ // DETAIL — one area, still summarised.
203
+ //
204
+ // The point of splitting this out is that an agent pays for depth only where it
205
+ // asked for it. Each branch returns the shape someone would actually reason
206
+ // with, not the rows.
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ export function buildDetail(state = {}, area = '', now = new Date()) {
210
+ const S = state || {}
211
+ const today = dayStr(now)
212
+ const key = String(area || '').toLowerCase().trim()
213
+
214
+ switch (key) {
215
+ case 'today': {
216
+ const tasks = arr(S.todayTasks)
217
+ return {
218
+ open: tasks.filter(t => !t.done).map(t => ({
219
+ text: t.text, at: t.at || null, category: t.category || null,
220
+ why: t.why || null, timer: t.timer || null, sticky: !!t.sticky,
221
+ })),
222
+ done: tasks.filter(t => t.done).map(t => t.text),
223
+ dropped: arr(S.missedTasks).slice(0, 20).map(t => t.text),
224
+ }
225
+ }
226
+ case 'habits':
227
+ return arr(S.habits).map(h => {
228
+ const days = habitDoneDays(h)
229
+ return { name: h.name || h.title, today: days.has(today), last_7: hitRate(days, 7, now), lifetime: days.size }
230
+ })
231
+ case 'stacks':
232
+ return arr(S.stacks).map(s => {
233
+ const days = stackDoneDays(s)
234
+ const target = num(s.graduationTarget) || 14
235
+ const window = Math.round(target * 1.5)
236
+ return {
237
+ name: s.name, cue: s.anchor || null, minimum: s.minimum || null,
238
+ hits: hitRate(days, window, now), target, window,
239
+ ready: hitRate(days, window, now) >= target,
240
+ today: days.has(today),
241
+ }
242
+ })
243
+ case 'goals': {
244
+ const frames = S.goals && typeof S.goals === 'object' ? S.goals : {}
245
+ return Object.entries(frames).flatMap(([frame, list]) => arr(list).map(g => ({
246
+ frame, text: g.text, bricks: `${num(g.earnedBricks)}/${num(g.targetBricks) || 20}`,
247
+ building: g.id === S.activeGoalId,
248
+ steps: arr(g.steps).length ? arr(g.steps).map(st => `${st.stage}: ${st.text}`) : null,
249
+ })))
250
+ }
251
+ case 'food': {
252
+ const day = arr(S.foodLog?.[today])
253
+ const tot = day.reduce((a, e) => {
254
+ const f = num(e.grams) / 100
255
+ return {
256
+ kcal: a.kcal + num(e.per100?.kcal) * f,
257
+ protein: a.protein + num(e.per100?.protein) * f,
258
+ carbs: a.carbs + num(e.per100?.carbs) * f,
259
+ fat: a.fat + num(e.per100?.fat) * f,
260
+ }
261
+ }, { kcal: 0, protein: 0, carbs: 0, fat: 0 })
262
+ return {
263
+ today: day.map(e => `${e.meal}: ${e.name} ${e.grams}${e.unit || 'g'}`),
264
+ totals: Object.fromEntries(Object.entries(tot).map(([k, v]) => [k, Math.round(v)])),
265
+ targets: S.foodTargets || null,
266
+ pantry_size: arr(S.pantry).length,
267
+ saved_meals: arr(S.meals).map(m => m.name),
268
+ }
269
+ }
270
+ case 'study':
271
+ return arr(S.studyMaps).map(m => ({
272
+ map: m.title, aim: m.blurb || null,
273
+ topics: arr(m.nodes).map(n => ({
274
+ title: n.title, status: n.status,
275
+ // The tutor's note is the memory of this topic — the single most
276
+ // useful thing an agent can read about someone's learning.
277
+ next: (n.note || '').split(/^##\s+Next\s*$/mi)[1]?.split('\n').map(l => l.trim()).find(Boolean) || null,
278
+ })),
279
+ }))
280
+ case 'lift':
281
+ return {
282
+ sessions: arr(S.liftSessions).slice(-8).map(s => ({
283
+ date: s.date, name: s.name || null,
284
+ exercises: arr(s.exercises).map(e => e.name).slice(0, 12),
285
+ })),
286
+ routines: arr(S.liftRoutines).map(r => r.name),
287
+ }
288
+ case 'mind': {
289
+ // The day note is composed of tagged segments; an agent wants the prose.
290
+ const segs = arr(S.moodDayJournals?.[today])
291
+ return {
292
+ today: arr(S.moods?.[today]).map(m => m.mood || m.key),
293
+ day_score: S.dayScores?.[today] || null,
294
+ note: segs.map(x => x.text).filter(Boolean).join(' ').slice(0, 800) || null,
295
+ }
296
+ }
297
+ case 'squares':
298
+ return {
299
+ total: num(S.totalSquares),
300
+ decay: S.decayState || null,
301
+ recent_losses: arr(S.decayLog).slice(-5),
302
+ }
303
+ case 'backlog':
304
+ return arr(S.backlogTasks).slice(0, 40).map(t => ({ text: t.text, horizon: t.horizon || null }))
305
+ case 'missions':
306
+ return arr(S.missions).map(m => ({ title: m.title, done: !!m.done, tasks: arr(m.taskIds).length }))
307
+ default:
308
+ return { error: `unknown area "${area}"`, known: Object.keys(buildBrief(S, now).areas) }
309
+ }
310
+ }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // ─────────────────────────────────────────────────────────────────────────────
3
- // TASK//SWIPE MCP SERVER — stdio transport.
3
+ // TASKKA MCP SERVER — stdio transport.
4
4
  //
5
5
  // Transport is deliberately the only thing in this file. Tools live in
6
6
  // tools.js and know nothing about MCP; state access lives in store.js and
@@ -23,39 +23,53 @@ import { TOOLS, byName } from './tools.js'
23
23
 
24
24
  const log = (...a) => process.stderr.write(a.join(' ') + '\n')
25
25
 
26
- const server = new Server(
27
- { name: 'taskswipe', version: '0.1.0' },
28
- { capabilities: { tools: {} } },
29
- )
26
+ async function startServer() {
27
+ const server = new Server(
28
+ { name: 'taskswipe', version: '0.2.0' },
29
+ { capabilities: { tools: {} } },
30
+ )
30
31
 
31
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
- tools: TOOLS.map(t => ({
33
- name: t.name,
34
- title: t.title,
35
- description: t.description,
36
- inputSchema: t.inputSchema,
37
- })),
38
- }))
32
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
+ tools: TOOLS.map(t => ({
34
+ name: t.name,
35
+ title: t.title,
36
+ description: t.description,
37
+ inputSchema: t.inputSchema,
38
+ })),
39
+ }))
39
40
 
40
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
41
- const tool = byName[req.params.name]
42
- if (!tool) {
43
- return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }] }
44
- }
45
- try {
46
- const result = await tool.run(req.params.arguments || {})
47
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
48
- } catch (e) {
49
- // Returned as a tool error rather than thrown: the model can read it and
50
- // tell the user what to fix, instead of the client showing "server error".
51
- log(`[taskswipe] ${tool.name} failed:`, e?.message || e)
52
- return {
53
- isError: true,
54
- content: [{ type: 'text', text: `TASK//SWIPE: ${e?.message || 'unknown error'}` }],
41
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
42
+ const tool = byName[req.params.name]
43
+ if (!tool) {
44
+ return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }] }
55
45
  }
56
- }
57
- })
46
+ try {
47
+ const result = await tool.run(req.params.arguments || {})
48
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
49
+ } catch (e) {
50
+ // Returned as a tool error rather than thrown: the model can read it and
51
+ // tell the user what to fix, instead of the client showing "server error".
52
+ log(`[taskswipe] ${tool.name} failed:`, e?.message || e)
53
+ return {
54
+ isError: true,
55
+ content: [{ type: 'text', text: `TASKKA: ${e?.message || 'unknown error'}` }],
56
+ }
57
+ }
58
+ })
58
59
 
59
- const transport = new StdioServerTransport()
60
- await server.connect(transport)
61
- log('[taskswipe] MCP server ready on stdio —', TOOLS.length, 'tools')
60
+ const transport = new StdioServerTransport()
61
+ await server.connect(transport)
62
+ log('[taskswipe] MCP server ready on stdio —', TOOLS.length, 'tools')
63
+ }
64
+
65
+ try {
66
+ if (process.argv[2] === 'setup') {
67
+ const { runSetup } = await import('./setup.js')
68
+ await runSetup(process.argv.slice(3))
69
+ } else {
70
+ await startServer()
71
+ }
72
+ } catch (error) {
73
+ log(`TASKKA setup failed: ${error?.message || error}`)
74
+ process.exitCode = 1
75
+ }
package/src/setup.js ADDED
@@ -0,0 +1,175 @@
1
+ // One-time, interactive bootstrap for an always-on TASKKA agent.
2
+ //
3
+ // The account password is read from the terminal with echo disabled, used once
4
+ // to mint a dedicated Supabase session, and never written to disk. The agent
5
+ // then runs taskswipe-mcp without credentials in its config: the renewable
6
+ // session lives in ~/.taskswipe-mcp-session.json with owner-only permissions.
7
+
8
+ import { spawnSync } from 'node:child_process'
9
+ import process from 'node:process'
10
+ import { persistSession, SESSION_FILE, supa } from './store.js'
11
+
12
+ const SKILL_URL = 'https://taskka.app/.well-known/skills/taskka/SKILL.md'
13
+ const PACKAGE = 'taskswipe-mcp@latest'
14
+
15
+ export function parseSetupArgs(argv = []) {
16
+ const target = String(argv[0] || '').toLowerCase()
17
+ let email = ''
18
+ for (let i = 1; i < argv.length; i += 1) {
19
+ if (argv[i] === '--email') email = String(argv[++i] || '').trim()
20
+ else if (String(argv[i]).startsWith('--email=')) email = String(argv[i]).slice(8).trim()
21
+ else throw new Error(`Unknown setup option: ${argv[i]}`)
22
+ }
23
+ if (!['hermes', 'openclaw'].includes(target)) {
24
+ throw new Error('Usage: taskswipe-mcp setup <hermes|openclaw> [--email you@example.com]')
25
+ }
26
+ return { target, email }
27
+ }
28
+
29
+ export function setupPlan(target) {
30
+ if (target === 'hermes') {
31
+ return [
32
+ {
33
+ label: 'Registering TASKKA with Hermes',
34
+ command: 'hermes',
35
+ args: ['mcp', 'add', 'taskka', '--command', 'npx', '--args', '-y', PACKAGE],
36
+ },
37
+ {
38
+ label: 'Installing the TASKKA skill',
39
+ command: 'hermes',
40
+ args: ['skills', 'install', SKILL_URL],
41
+ optional: true,
42
+ },
43
+ {
44
+ label: 'Testing the MCP connection',
45
+ command: 'hermes',
46
+ args: ['mcp', 'test', 'taskka'],
47
+ },
48
+ ]
49
+ }
50
+ if (target === 'openclaw') {
51
+ return [
52
+ {
53
+ label: 'Registering TASKKA with OpenClaw',
54
+ command: 'openclaw',
55
+ args: ['mcp', 'add', 'taskka', '--command', 'npx', '--arg', '-y', '--arg', PACKAGE],
56
+ },
57
+ {
58
+ label: 'Installing the TASKKA skill',
59
+ command: 'openclaw',
60
+ args: ['skills', 'install', SKILL_URL],
61
+ optional: true,
62
+ },
63
+ {
64
+ label: 'Testing the MCP connection',
65
+ command: 'openclaw',
66
+ args: ['mcp', 'doctor', 'taskka', '--probe'],
67
+ },
68
+ ]
69
+ }
70
+ throw new Error(`Unsupported setup target: ${target}`)
71
+ }
72
+
73
+ function readVisible(question) {
74
+ return new Promise((resolve, reject) => {
75
+ process.stdout.write(question)
76
+ let value = ''
77
+ const onData = chunk => {
78
+ const text = chunk.toString('utf8')
79
+ const end = text.indexOf('\n')
80
+ if (end !== -1) {
81
+ cleanup()
82
+ resolve((value + text.slice(0, end)).replace(/\r$/, '').trim())
83
+ } else value += text
84
+ }
85
+ const onError = error => { cleanup(); reject(error) }
86
+ const cleanup = () => {
87
+ process.stdin.off('data', onData)
88
+ process.stdin.off('error', onError)
89
+ process.stdin.pause()
90
+ }
91
+ process.stdin.on('data', onData)
92
+ process.stdin.on('error', onError)
93
+ process.stdin.resume()
94
+ })
95
+ }
96
+
97
+ function readSecret(question) {
98
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
99
+ throw new Error('Password entry needs an interactive terminal. Re-run this command directly over SSH.')
100
+ }
101
+ return new Promise((resolve, reject) => {
102
+ const value = []
103
+ process.stdout.write(question)
104
+ process.stdin.setRawMode(true)
105
+ process.stdin.resume()
106
+
107
+ const finish = (error) => {
108
+ process.stdin.off('data', onData)
109
+ process.stdin.setRawMode(false)
110
+ process.stdin.pause()
111
+ process.stdout.write('\n')
112
+ if (error) reject(error)
113
+ else resolve(Buffer.from(value).toString('utf8'))
114
+ }
115
+ const onData = chunk => {
116
+ for (const byte of chunk) {
117
+ if (byte === 3) return finish(new Error('Setup cancelled.'))
118
+ if (byte === 13 || byte === 10) return finish()
119
+ if (byte === 8 || byte === 127) {
120
+ if (value.length) {
121
+ value.pop()
122
+ process.stdout.write('\b \b')
123
+ }
124
+ } else if (byte >= 32) {
125
+ value.push(byte)
126
+ process.stdout.write('•')
127
+ }
128
+ }
129
+ }
130
+ process.stdin.on('data', onData)
131
+ })
132
+ }
133
+
134
+ function runStep(step) {
135
+ process.stdout.write(`\n${step.label}…\n`)
136
+ const result = spawnSync(step.command, step.args, { stdio: 'inherit' })
137
+ if (result.error?.code === 'ENOENT') {
138
+ throw new Error(`${step.command} is not installed or is not on PATH.`)
139
+ }
140
+ if (result.error) throw result.error
141
+ if (result.status !== 0) {
142
+ if (step.optional) {
143
+ process.stderr.write(`TASKKA session is connected, but this optional step did not finish.\n`)
144
+ return
145
+ }
146
+ throw new Error(`${step.label} failed (exit ${result.status ?? 'unknown'}).`)
147
+ }
148
+ }
149
+
150
+ export async function runSetup(argv = process.argv.slice(3)) {
151
+ const { target, email: suppliedEmail } = parseSetupArgs(argv)
152
+ if (!process.stdin.isTTY) {
153
+ throw new Error('One-time setup needs an interactive terminal. Run it directly in your VPS SSH session.')
154
+ }
155
+
156
+ process.stdout.write('\nTASKKA · ONE-TIME VPS SETUP\n')
157
+ process.stdout.write('Your password is used once to create this agent session. It is never saved.\n\n')
158
+ const email = suppliedEmail || await readVisible('TASKKA email: ')
159
+ if (!email || !email.includes('@')) throw new Error('Enter a valid TASKKA email address.')
160
+ let password = await readSecret('TASKKA password: ')
161
+ if (!password) throw new Error('Password cannot be blank.')
162
+
163
+ const { data, error } = await supa().auth.signInWithPassword({ email, password })
164
+ password = ''
165
+ if (error || !data?.session || !data?.user) {
166
+ throw new Error(`TASKKA sign-in failed: ${error?.message || 'no session returned'}`)
167
+ }
168
+ persistSession(data.session)
169
+ process.stdout.write(`Signed in as ${data.user.email || email}. Renewable session saved to ${SESSION_FILE}\n`)
170
+
171
+ for (const step of setupPlan(target)) runStep(step)
172
+
173
+ process.stdout.write(`\n✓ TASKKA is paired with ${target === 'hermes' ? 'Hermes' : 'OpenClaw'}.\n`)
174
+ process.stdout.write('You can leave it alone now; the MCP session renews itself.\n')
175
+ }
package/src/store.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // STATE ACCESS — read/modify/write the one JSONB blob, safely.
3
3
  //
4
- // TASK//SWIPE keeps everything in a single `user_states.state` row, upserted
4
+ // TASKKA keeps everything in a single `user_states.state` row, upserted
5
5
  // whole. With one writer that is fine. MCP adds a SECOND writer, and a plain
6
6
  // upsert means last-write-wins — the web app saves, we save a moment later
7
7
  // from a stale read, and everything it just did is silently gone.
@@ -80,7 +80,7 @@ export function supa() {
80
80
  // So the pasted-into-config token pair is only ever the SEED: after the first
81
81
  // refresh, the current pair lives in this file and the env values are stale by
82
82
  // design. Delete the file to start over from the env seed.
83
- const SESSION_FILE = path.join(os.homedir(), '.taskswipe-mcp-session.json')
83
+ export const SESSION_FILE = path.join(os.homedir(), '.taskswipe-mcp-session.json')
84
84
 
85
85
  function loadSavedSession() {
86
86
  try {
@@ -89,17 +89,25 @@ function loadSavedSession() {
89
89
  } catch { return null }
90
90
  }
91
91
 
92
+ export function persistSession(session) {
93
+ fs.writeFileSync(
94
+ SESSION_FILE,
95
+ JSON.stringify({
96
+ access_token: session.access_token,
97
+ refresh_token: session.refresh_token,
98
+ expires_at: session.expires_at || null,
99
+ }),
100
+ { mode: 0o600 },
101
+ )
102
+ // writeFile's mode only applies when a file is first created. Correct an
103
+ // older or manually-created file too, so the credential is always private.
104
+ fs.chmodSync(SESSION_FILE, 0o600)
105
+ return SESSION_FILE
106
+ }
107
+
92
108
  function saveSession(session) {
93
109
  try {
94
- fs.writeFileSync(
95
- SESSION_FILE,
96
- JSON.stringify({
97
- access_token: session.access_token,
98
- refresh_token: session.refresh_token,
99
- expires_at: session.expires_at || null,
100
- }),
101
- { mode: 0o600 }, // it's a credential — owner-only, like an SSH key
102
- )
110
+ persistSession(session)
103
111
  } catch (e) {
104
112
  console.error('[taskswipe] could not persist session:', e?.message)
105
113
  }
package/src/tools.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // ─────────────────────────────────────────────────────────────────────────────
15
15
 
16
16
  import { readState, mutate } from './store.js'
17
+ import { buildBrief, buildDetail } from './brief.js'
17
18
 
18
19
  const MAX_ADD = 10
19
20
  const uid = () => Math.random().toString(36).slice(2, 11)
@@ -23,6 +24,27 @@ const today = () => {
23
24
  }
24
25
 
25
26
  const arr = (v) => (Array.isArray(v) ? v : [])
27
+ const num = (v) => (Number.isFinite(+v) ? +v : 0)
28
+
29
+ // Mirrors GOAL_FRAMES and creditGoal in src/lib/goals.js. Duplicated rather
30
+ // than imported because this package ships standalone to npm and cannot reach
31
+ // into the app's src/. If the goal model changes there, change it here too.
32
+ const GOAL_FRAMES = ['3month', '6month', 'yearly', '3year', '7year']
33
+ function creditGoal(goals = {}, { goalId, goalFrame, squares }) {
34
+ if (!goalId || !squares) return goals || {}
35
+ const frames = goalFrame && GOAL_FRAMES.includes(goalFrame) ? [goalFrame] : GOAL_FRAMES
36
+ const next = { ...(goals || {}) }
37
+ for (const frame of frames) {
38
+ const list = arr(next[frame])
39
+ const idx = list.findIndex(g => g.id === goalId)
40
+ if (idx < 0) continue
41
+ next[frame] = list.map((g, i) => (i === idx
42
+ ? { ...g, earnedBricks: Math.max(0, num(g.earnedBricks) + squares) }
43
+ : g))
44
+ return next
45
+ }
46
+ return goals || {}
47
+ }
26
48
 
27
49
  /** Match a task the way a person refers to it: by substring, not by id. */
28
50
  function findTask(list, ref) {
@@ -43,11 +65,53 @@ function findTask(list, ref) {
43
65
  }
44
66
 
45
67
  export const TOOLS = [
68
+ // FIRST CALL, ALWAYS. Six tools covering three of twenty-five tabs left an
69
+ // agent guessing what existed and stitching several reads into a picture. One
70
+ // bounded call now describes the whole system — see brief.js for why it
71
+ // summarises rather than serialising.
72
+ {
73
+ name: 'brief',
74
+ title: 'The whole picture, briefly',
75
+ description:
76
+ 'START HERE for anything about how the user is doing, what they should do '
77
+ + 'next, or what is going on in any part of their life. Returns a compact '
78
+ + 'snapshot of the ENTIRE app — today, what needs attention ranked by '
79
+ + 'severity, and a one-line state for every area (habits, stacks, goals, '
80
+ + 'food, lift, walk, mind, study, squares and the rest). About a kilobyte '
81
+ + 'whether they joined yesterday or two years ago. Follow with detail() '
82
+ + 'for depth on one area.',
83
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
84
+ async run() {
85
+ const { state } = await readState()
86
+ return buildBrief(state)
87
+ },
88
+ },
89
+
90
+ {
91
+ name: 'detail',
92
+ title: 'One area, in depth',
93
+ description:
94
+ 'Depth on ONE area named by brief(). Pass any key from the brief\'s '
95
+ + '`areas` — today, habits, stacks, goals, food, lift, mind, study, '
96
+ + 'squares, backlog, missions. Still summarised: you get what someone '
97
+ + 'would reason with, not the raw rows.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: { area: { type: 'string', description: 'e.g. "habits", "food", "study"' } },
101
+ required: ['area'],
102
+ additionalProperties: false,
103
+ },
104
+ async run({ area }) {
105
+ const { state } = await readState()
106
+ return buildDetail(state, area)
107
+ },
108
+ },
109
+
46
110
  {
47
111
  name: 'today',
48
112
  title: "Today's plan",
49
113
  description:
50
- "What TASK//SWIPE has for today: open and completed tasks, the core task, "
114
+ "What TASKKA has for today: open and completed tasks, the core task, "
51
115
  + "the day score if posted, and today's timed blocks. Call this before "
52
116
  + "answering anything about what the user is doing today.",
53
117
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
@@ -130,10 +194,19 @@ export const TOOLS = [
130
194
  + 'a few words is enough, no id needed. Returns the exact task matched.',
131
195
  inputSchema: {
132
196
  type: 'object',
133
- properties: { task: { type: 'string', minLength: 1, description: 'Any part of the task text.' } },
197
+ properties: {
198
+ task: { type: 'string', minLength: 1, description: 'Any part of the task text.' },
199
+ squares: {
200
+ type: 'integer', minimum: 0, maximum: 48,
201
+ description:
202
+ 'How many squares the task was worth. Omit unless the user says — '
203
+ + 'a recurring rule with a preset worth supplies its own, and '
204
+ + 'anything else is worth 0 until the user values it in the app.',
205
+ },
206
+ },
134
207
  required: ['task'], additionalProperties: false,
135
208
  },
136
- async run({ task }) {
209
+ async run({ task, squares }) {
137
210
  let result = null
138
211
  await mutate(state => {
139
212
  const list = arr(state.todayTasks)
@@ -141,10 +214,52 @@ export const TOOLS = [
141
214
  if (!hit) { result = { ok: false, reason: 'no_match' }; return null }
142
215
  if (hit.ambiguous) { result = { ok: false, reason: 'ambiguous', candidates: hit.ambiguous }; return null }
143
216
  if (hit.done) { result = { ok: true, already: true, task: hit.text }; return null }
144
- result = { ok: true, task: hit.text }
217
+
218
+ // Mirrors the COMPLETE_TASK reducer in src/store.jsx. Ticking a task is
219
+ // not just a flag: it earns squares, writes a history entry, feeds the
220
+ // level counter, and credits any linked stack or goal. Setting `done`
221
+ // alone — which is all this did — silently dropped every one of those,
222
+ // so a recurring task configured as worth 8 earned nothing when ticked
223
+ // from here. Keep the two in step.
224
+ //
225
+ // Coerce like the reducer does: one non-finite value would make
226
+ // totalSquares NaN, and NaN survives every later addition, quietly
227
+ // destroying the currency for good.
228
+ const asked = squares !== undefined ? squares : hit.presetSquares
229
+ const earned = Number.isFinite(+asked) ? Math.max(0, +asked) : 0
230
+
231
+ const newToday = list.map(t => (t.id === hit.id
232
+ ? {
233
+ ...t, done: true, completedAt: Date.now(),
234
+ // Settling the parent settles its steps, as the app does.
235
+ ...(t.subtasks ? { subtasks: t.subtasks.map(sub => ({ ...sub, done: true })) } : {}),
236
+ }
237
+ : t))
238
+
239
+ const entry = {
240
+ ...hit, done: true, squares: earned,
241
+ stackId: hit.stackId, notes: '', at: Date.now(), source: 'mcp',
242
+ }
243
+ if (!entry.goalId) { delete entry.goalId; delete entry.goalFrame }
244
+
245
+ const newTotal = num(state.totalSquares) + earned
246
+ const leveledUp = earned > 0
247
+ && Math.floor(num(state.totalSquares) / 48) !== Math.floor(newTotal / 48)
248
+
249
+ const newStacks = hit.stackId
250
+ ? arr(state.stacks).map(st => (st.id === hit.stackId
251
+ ? { ...st, completions: [...arr(st.completions), { date: today(), task: hit.text }] }
252
+ : st))
253
+ : state.stacks
254
+
255
+ result = { ok: true, task: hit.text, squares: earned, total: newTotal, leveledUp }
145
256
  return {
146
257
  ...state,
147
- todayTasks: list.map(t => (t.id === hit.id ? { ...t, done: true, completedAt: Date.now() } : t)),
258
+ todayTasks: newToday,
259
+ achievedTasks: [entry, ...arr(state.achievedTasks)],
260
+ totalSquares: newTotal,
261
+ stacks: newStacks,
262
+ goals: earned > 0 ? creditGoal(state.goals, { goalId: entry.goalId, goalFrame: entry.goalFrame, squares: earned }) : state.goals,
148
263
  }
149
264
  })
150
265
  if (!result?.ok && result?.reason === 'no_match') {
@@ -153,7 +268,15 @@ export const TOOLS = [
153
268
  if (result?.reason === 'ambiguous') {
154
269
  return { ok: false, message: `That matches several: ${result.candidates.join(', ')}. Be more specific.` }
155
270
  }
156
- return { ok: true, message: result.already ? `"${result.task}" was already done.` : `Ticked off "${result.task}".` }
271
+ if (result.already) return { ok: true, message: `"${result.task}" was already done.` }
272
+ const earned = result.squares
273
+ ? ` +${result.squares} square${result.squares === 1 ? '' : 's'} (${result.total} total)`
274
+ : ''
275
+ return {
276
+ ok: true,
277
+ message: `Ticked off "${result.task}"${earned}.`
278
+ + (result.leveledUp ? ' That completed a brick — level up.' : ''),
279
+ }
157
280
  },
158
281
  },
159
282