taskswipe-mcp 0.1.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 ADDED
@@ -0,0 +1,175 @@
1
+ # taskswipe-mcp
2
+
3
+ Your TASK//SWIPE day, from inside Claude Code, Claude Desktop, Codex or Cursor.
4
+
5
+ ```
6
+ you: add "call the bank" and "book the dentist" to today
7
+ you: what's on today?
8
+ you: tick off the bank one
9
+ ```
10
+
11
+ ## Two ways to connect
12
+
13
+ **Hosted (nothing to install)** — point any remote-MCP client at:
14
+
15
+ ```
16
+ https://swipe-todo-react.vercel.app/mcp
17
+ ```
18
+
19
+ with your token as `Authorization: Bearer <TASKSWIPE_ACCESS_TOKEN>`. Stateless
20
+ Streamable HTTP, POST-only. Discovery (`initialize`, `tools/list`) is open;
21
+ every `tools/call` requires the token and runs scoped to that user, so
22
+ row-level security still applies and no service key exists anywhere.
23
+
24
+ **Local (stdio)** — the rest of this file. Still the right choice when you want
25
+ the server reading credentials from your own machine rather than sending a
26
+ token over the wire.
27
+
28
+ ## Setup
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`.
34
+
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.
39
+
40
+ **Claude Code**
41
+
42
+ ```bash
43
+ claude mcp add taskswipe \
44
+ -e TASKSWIPE_SUPABASE_URL=https://xxxx.supabase.co \
45
+ -e TASKSWIPE_SUPABASE_ANON_KEY=eyJ... \
46
+ -e TASKSWIPE_ACCESS_TOKEN=eyJ... \
47
+ -- node ~/Developer/swipe-todo-react/mcp/src/index.js
48
+ ```
49
+
50
+ **Codex CLI** — Codex does **not** read the JSON `mcpServers` shape. It has its
51
+ own TOML config at `~/.codex/config.toml`. Either run:
52
+
53
+ ```bash
54
+ codex mcp add taskswipe \
55
+ --env TASKSWIPE_SUPABASE_URL=https://xxxx.supabase.co \
56
+ --env TASKSWIPE_SUPABASE_ANON_KEY=eyJ... \
57
+ --env TASKSWIPE_ACCESS_TOKEN=eyJ... \
58
+ -- node ~/Developer/swipe-todo-react/mcp/src/index.js
59
+ ```
60
+
61
+ or add the section by hand:
62
+
63
+ ```toml
64
+ [mcp_servers.taskswipe]
65
+ command = "node"
66
+ args = ["/Users/you/Developer/swipe-todo-react/mcp/src/index.js"]
67
+
68
+ [mcp_servers.taskswipe.env]
69
+ TASKSWIPE_SUPABASE_URL = "https://xxxx.supabase.co"
70
+ TASKSWIPE_SUPABASE_ANON_KEY = "eyJ..."
71
+ TASKSWIPE_ACCESS_TOKEN = "eyJ..."
72
+ ```
73
+
74
+ (`codex mcp list` to confirm it registered; note the section is
75
+ `mcp_servers`, snake_case, not `mcpServers`.)
76
+
77
+ **Claude Desktop / Cursor** — these do use the JSON shape
78
+ (`claude_desktop_config.json` / `.cursor/mcp.json`):
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "taskswipe": {
84
+ "command": "node",
85
+ "args": ["/Users/you/Developer/swipe-todo-react/mcp/src/index.js"],
86
+ "env": {
87
+ "TASKSWIPE_SUPABASE_URL": "https://xxxx.supabase.co",
88
+ "TASKSWIPE_SUPABASE_ANON_KEY": "eyJ...",
89
+ "TASKSWIPE_ACCESS_TOKEN": "eyJ..."
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Getting an access token
97
+
98
+ Sign in to TASK//SWIPE in a browser, open the console, and run:
99
+
100
+ ```js
101
+ JSON.parse(Object.entries(localStorage).find(([k]) => k.includes('auth-token'))[1]).access_token
102
+ ```
103
+
104
+ It expires on its own, which is the point — a leaked token stops working.
105
+
106
+ ### Making it permanent
107
+
108
+ Add `TASKSWIPE_REFRESH_TOKEN` next to it — same console snippet, ending in
109
+ `.refresh_token` instead of `.access_token`. With the pair in place the server
110
+ renews itself and keeps the CURRENT session in `~/.taskswipe-mcp-session.json`
111
+ (owner-only file). That file matters: Supabase rotates refresh tokens on every
112
+ use and treats reuse of an old one as theft, so the config values are only the
113
+ seed — after first run the file is the live credential. Signing out of that
114
+ session (or deleting the file AND pasting a fresh pair) is the reset path.
115
+
116
+ ### Password fallback
117
+
118
+ `TASKSWIPE_EMAIL` + `TASKSWIPE_PASSWORD` also work, and the server prefers a
119
+ token whenever both are present. Understand the trade before using it: MCP
120
+ config files sit unencrypted in your home directory, are frequently committed
121
+ by accident, and are read by every tool on the machine. A password there is a
122
+ permanent credential for your whole account — it does not expire, and rotating
123
+ it means changing it everywhere. The server prints a warning to stderr when it
124
+ signs in this way.
125
+
126
+ ## Codex marketplace
127
+
128
+ The repo root carries `.codex-plugin/plugin.json` + `.mcp.json`, which makes
129
+ the whole repo an installable Codex plugin. The path to being listed
130
+ (as of 2026-08):
131
+
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.
135
+ 2. **Interim listing** — submit the GitHub repo URL to the community registry
136
+ at codex-marketplace.com (automated review of the manifest).
137
+ 3. **Official directory** — OpenAI's self-serve publishing is "coming soon";
138
+ until then it's a manual submission portal and requires identity
139
+ verification (individual or business) first.
140
+
141
+ Users who install it set only `TASKSWIPE_ACCESS_TOKEN` (+
142
+ `TASKSWIPE_REFRESH_TOKEN` to make it permanent) in their own config.
143
+
144
+ ## Tools
145
+
146
+ | Tool | Does |
147
+ |---|---|
148
+ | `today` | Today's open/done tasks, core task, day score |
149
+ | `add_tasks` | Add up to 10 tasks at once, skipping duplicates |
150
+ | `complete_task` | Tick one off by any part of its text |
151
+ | `capture` | Park an idea (not a task) |
152
+ | `set_core_task` | Set the one thing that matters today |
153
+ | `search` | Across tasks, ideas, journal, lists, backlog |
154
+
155
+ ## What it deliberately cannot do
156
+
157
+ - **Nothing destructive.** No delete, no clear, no reset. The worst a wrong
158
+ call can do is add a task you remove or tick one you untick.
159
+ - **10 writes per call, maximum**, and anything over the limit is reported
160
+ rather than silently dropped.
161
+ - **Everything it writes is tagged `source: 'mcp'`**, so the app can show what
162
+ came from an assistant.
163
+
164
+ ## Concurrency
165
+
166
+ The app stores everything as one JSON blob. Two writers means the last one
167
+ wins and silently discards the other's work, so every write here is conditional
168
+ on the row not having changed since it was read — a stale write re-reads and
169
+ re-applies rather than overwriting. See `src/store.js`.
170
+
171
+ ## Security
172
+
173
+ Your credentials stay on your machine and are used to sign in as *you*, so
174
+ Supabase row-level security still applies — this server can only ever touch
175
+ your own row. No service key, and nothing is proxied through a third party.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
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.",
5
+ "type": "module",
6
+ "bin": {
7
+ "taskswipe-mcp": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.0.0",
17
+ "@supabase/supabase-js": "^2.45.0"
18
+ }
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // TASK//SWIPE MCP SERVER — stdio transport.
4
+ //
5
+ // Transport is deliberately the only thing in this file. Tools live in
6
+ // tools.js and know nothing about MCP; state access lives in store.js and
7
+ // knows nothing about tools. When the hosted HTTP transport lands (Phase 3 of
8
+ // docs/MCP-STRATEGY.md) it replaces this file alone.
9
+ //
10
+ // stdio has one rule that bites everyone once: STDOUT IS THE PROTOCOL. A stray
11
+ // console.log corrupts the JSON-RPC stream and the client disconnects with a
12
+ // parse error that points nowhere near the cause. Everything diagnostic goes
13
+ // to stderr.
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
18
+ import {
19
+ CallToolRequestSchema,
20
+ ListToolsRequestSchema,
21
+ } from '@modelcontextprotocol/sdk/types.js'
22
+ import { TOOLS, byName } from './tools.js'
23
+
24
+ const log = (...a) => process.stderr.write(a.join(' ') + '\n')
25
+
26
+ const server = new Server(
27
+ { name: 'taskswipe', version: '0.1.0' },
28
+ { capabilities: { tools: {} } },
29
+ )
30
+
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
+ }))
39
+
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'}` }],
55
+ }
56
+ }
57
+ })
58
+
59
+ const transport = new StdioServerTransport()
60
+ await server.connect(transport)
61
+ log('[taskswipe] MCP server ready on stdio —', TOOLS.length, 'tools')
package/src/store.js ADDED
@@ -0,0 +1,257 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // STATE ACCESS — read/modify/write the one JSONB blob, safely.
3
+ //
4
+ // TASK//SWIPE keeps everything in a single `user_states.state` row, upserted
5
+ // whole. With one writer that is fine. MCP adds a SECOND writer, and a plain
6
+ // upsert means last-write-wins — the web app saves, we save a moment later
7
+ // from a stale read, and everything it just did is silently gone.
8
+ //
9
+ // So every write here is conditional on the `updated_at` we read. If the row
10
+ // moved underneath us the update matches zero rows, and we re-read and re-apply
11
+ // rather than overwrite. Three attempts, then an honest error instead of quiet
12
+ // data loss.
13
+ //
14
+ // (This same race already exists between two browser tabs. The lock below only
15
+ // protects writes made through MCP; fixing the app side is tracked separately.)
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ import { createClient } from '@supabase/supabase-js'
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+ import { AsyncLocalStorage } from 'node:async_hooks'
23
+
24
+ // ── REQUEST SCOPE ────────────────────────────────────────────────────────────
25
+ // Locally this server is one process for one person, so a module-level client
26
+ // and a cached user id are exactly right. Over HTTP the same code serves many
27
+ // people, and those two module globals would leak one user's data to the next
28
+ // request — the worst bug this file could possibly have.
29
+ //
30
+ // AsyncLocalStorage rather than a ctx argument threaded through every tool:
31
+ // tools.js calls readState()/mutate() directly and shouldn't have to know
32
+ // which transport it's running under. When a context is active it wins; with
33
+ // none, the env-configured singleton below behaves exactly as before.
34
+ const requestCtx = new AsyncLocalStorage()
35
+
36
+ /** Run `fn` with an explicit Supabase client + user id (HTTP transport). */
37
+ export const withRequestContext = (ctx, fn) => requestCtx.run(ctx, fn)
38
+
39
+ const activeClient = () => requestCtx.getStore()?.supa || supa()
40
+ const activeUserId = async () => requestCtx.getStore()?.userId || await userId()
41
+
42
+ // TASKSWIPE_FAKE swaps the database for an in-memory blob. Used by the test
43
+ // harness to drive the real protocol without real credentials; never set in
44
+ // normal use, and it changes nothing about the code paths above it.
45
+ const FAKE = process.env.TASKSWIPE_FAKE === '1'
46
+ let fakeState = {}
47
+ let fakeStamp = null
48
+
49
+ const MAX_ATTEMPTS = 3
50
+
51
+ let client = null
52
+ let cachedUserId = null
53
+
54
+ function env(name, fallback) {
55
+ const v = process.env[name] ?? fallback
56
+ if (!v) throw new Error(`Missing ${name}. See the README for setup.`)
57
+ return v
58
+ }
59
+
60
+ // The production project's URL and anon key. These are PUBLIC by design —
61
+ // they ship inside the web app's JS bundle for every visitor — so baking them
62
+ // in leaks nothing and means an installed plugin needs exactly one thing from
63
+ // the user: their own token. Self-hosters override with the env vars.
64
+ const DEFAULT_URL = 'https://pfwckeghmflerxrtvwpa.supabase.co'
65
+ const DEFAULT_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBmd2NrZWdobWZsZXJ4cnR2d3BhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg4Njc0NzQsImV4cCI6MjA5NDQ0MzQ3NH0.lhj5zKJT4bR7hvnXQWQHfStm9r4BfrRQ6_LFcxOUJLk'
66
+
67
+ export function supa() {
68
+ if (client) return client
69
+ client = createClient(
70
+ env('TASKSWIPE_SUPABASE_URL', DEFAULT_URL),
71
+ env('TASKSWIPE_SUPABASE_ANON_KEY', DEFAULT_ANON_KEY),
72
+ { auth: { persistSession: false, autoRefreshToken: false } },
73
+ )
74
+ return client
75
+ }
76
+
77
+ // ── SESSION PERSISTENCE ──────────────────────────────────────────────────────
78
+ // An access token lives for an hour; a Supabase refresh token ROTATES every
79
+ // time it's used, and reusing a rotated one revokes the whole session family.
80
+ // So the pasted-into-config token pair is only ever the SEED: after the first
81
+ // refresh, the current pair lives in this file and the env values are stale by
82
+ // design. Delete the file to start over from the env seed.
83
+ const SESSION_FILE = path.join(os.homedir(), '.taskswipe-mcp-session.json')
84
+
85
+ function loadSavedSession() {
86
+ try {
87
+ const j = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'))
88
+ return j?.access_token && j?.refresh_token ? j : null
89
+ } catch { return null }
90
+ }
91
+
92
+ function saveSession(session) {
93
+ 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
+ )
103
+ } catch (e) {
104
+ console.error('[taskswipe] could not persist session:', e?.message)
105
+ }
106
+ }
107
+
108
+ const jwtExp = (token) => {
109
+ try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).exp || 0 }
110
+ catch { return 0 }
111
+ }
112
+
113
+ /**
114
+ * Make sure the session in `s` has more than 5 minutes left, refreshing and
115
+ * re-persisting if not. Re-reads the file first so two servers (Claude and
116
+ * Codex both running) pick up each other's rotations instead of burning a
117
+ * stale refresh token, which Supabase treats as theft and revokes everything.
118
+ */
119
+ async function ensureFresh(s) {
120
+ const { data } = await s.auth.getSession()
121
+ let session = data?.session
122
+ if (session && jwtExp(session.access_token) - Date.now() / 1000 > 300) return
123
+
124
+ const saved = loadSavedSession()
125
+ const refresh = saved?.refresh_token || session?.refresh_token || process.env.TASKSWIPE_REFRESH_TOKEN
126
+ if (!refresh) return // nothing to refresh with — ride the token out
127
+
128
+ const { data: r, error } = await s.auth.refreshSession({ refresh_token: refresh })
129
+ if (error || !r?.session) {
130
+ throw new Error(
131
+ 'Session expired and the refresh failed'
132
+ + (error?.message ? ` (${error.message})` : '')
133
+ + '. Paste a fresh token pair into the config (see the README), and delete '
134
+ + SESSION_FILE + ' if it exists.',
135
+ )
136
+ }
137
+ saveSession(r.session)
138
+ cachedUserId = r.session.user?.id || cachedUserId
139
+ }
140
+
141
+ /**
142
+ * Sign in once with the user's own credentials and keep the session for the
143
+ * process lifetime. Deliberately their credentials, not a service key: the
144
+ * server then has exactly the access the user has, and RLS still applies.
145
+ */
146
+ export async function userId() {
147
+ if (cachedUserId) { await ensureFresh(supa()); return cachedUserId }
148
+ const s = supa()
149
+
150
+ // The persisted session outranks the env seed — after the first rotation
151
+ // the env tokens are dead history and trying them first would revoke us.
152
+ const saved = loadSavedSession()
153
+ if (saved) {
154
+ const { data, error } = await s.auth.setSession(saved)
155
+ if (!error && data?.user) {
156
+ cachedUserId = data.user.id
157
+ if (data.session) saveSession(data.session) // setSession may have rotated
158
+ await ensureFresh(s)
159
+ return cachedUserId
160
+ }
161
+ console.error('[taskswipe] saved session rejected — falling back to the env seed')
162
+ }
163
+
164
+ const token = process.env.TASKSWIPE_ACCESS_TOKEN
165
+ if (token) {
166
+ const refresh = process.env.TASKSWIPE_REFRESH_TOKEN || ''
167
+ if (refresh) {
168
+ // Full pair: hand it to setSession, which refreshes if the access token
169
+ // is already stale — so a config pasted yesterday still signs in today.
170
+ const { data, error } = await s.auth.setSession({ access_token: token, refresh_token: refresh })
171
+ if (error || !data?.user) {
172
+ throw new Error(`Could not start a session from the configured tokens${error?.message ? ` (${error.message})` : ''}. Paste a fresh pair — see the README.`)
173
+ }
174
+ if (data.session) saveSession(data.session)
175
+ cachedUserId = data.user.id
176
+ return cachedUserId
177
+ }
178
+ // Access token alone: works until it expires, and can't renew itself —
179
+ // said out loud so "it stopped after an hour" is a warning, not a mystery.
180
+ const { data, error } = await s.auth.getUser(token)
181
+ if (error || !data?.user) throw new Error('TASKSWIPE_ACCESS_TOKEN is not valid or has expired.')
182
+ await s.auth.setSession({ access_token: token, refresh_token: '' })
183
+ console.error('[taskswipe] No TASKSWIPE_REFRESH_TOKEN set — this session dies with the access token (~1h). Add the refresh token to make it permanent.')
184
+ cachedUserId = data.user.id
185
+ return cachedUserId
186
+ }
187
+
188
+ // A password in an MCP config is a non-expiring credential for the whole
189
+ // account, sitting in plaintext in a file that gets committed by accident all
190
+ // the time. It still works — but it should never be the silent default, so
191
+ // say so. stderr, never stdout: stdout is the protocol.
192
+ console.error(
193
+ '[taskswipe] Signing in with a stored password. Prefer TASKSWIPE_ACCESS_TOKEN — '
194
+ + 'it expires on its own. See the README.',
195
+ )
196
+
197
+ const email = env('TASKSWIPE_EMAIL')
198
+ const password = env('TASKSWIPE_PASSWORD')
199
+ const { data, error } = await s.auth.signInWithPassword({ email, password })
200
+ if (error || !data?.user) throw new Error(`Sign-in failed: ${error?.message || 'unknown error'}`)
201
+ if (data.session) saveSession(data.session)
202
+ cachedUserId = data.user.id
203
+ return cachedUserId
204
+ }
205
+
206
+ /** Current state plus the stamp a later write must match. */
207
+ export async function readState() {
208
+ if (FAKE) return { state: fakeState, updatedAt: fakeStamp, userId: 'fake-user' }
209
+ const id = await activeUserId()
210
+ const { data, error } = await activeClient()
211
+ .from('user_states')
212
+ .select('state, updated_at')
213
+ .eq('user_id', id)
214
+ .maybeSingle()
215
+ if (error) throw new Error(`Could not read your data: ${error.message}`)
216
+ return { state: data?.state || {}, updatedAt: data?.updated_at || null, userId: id }
217
+ }
218
+
219
+ /**
220
+ * Apply `patch(state)` and save, retrying if someone else wrote first.
221
+ * `patch` must be pure and repeatable — it can be called more than once.
222
+ */
223
+ export async function mutate(patch) {
224
+ if (FAKE) {
225
+ const next = patch(structuredClone(fakeState))
226
+ if (!next) return { changed: false, state: fakeState }
227
+ fakeState = next
228
+ fakeStamp = new Date().toISOString()
229
+ return { changed: true, state: next }
230
+ }
231
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
232
+ const { state, updatedAt, userId: id } = await readState()
233
+ const next = patch(structuredClone(state))
234
+ if (!next) return { changed: false, state }
235
+
236
+ const stamp = new Date().toISOString()
237
+ let q = activeClient().from('user_states')
238
+ .update({ state: next, updated_at: stamp })
239
+ .eq('user_id', id)
240
+
241
+ // The lock. A row whose updated_at moved is a row someone else wrote.
242
+ q = updatedAt ? q.eq('updated_at', updatedAt) : q.is('updated_at', null)
243
+
244
+ const { data, error } = await q.select('user_id')
245
+ if (error) throw new Error(`Could not save: ${error.message}`)
246
+ if (data && data.length) return { changed: true, state: next }
247
+
248
+ // No rows matched — either a concurrent write, or no row exists yet.
249
+ if (!updatedAt) {
250
+ const { error: insErr } = await activeClient().from('user_states')
251
+ .insert({ user_id: id, state: next, updated_at: stamp })
252
+ if (!insErr) return { changed: true, state: next }
253
+ }
254
+ // else: fall through and retry from a fresh read
255
+ }
256
+ throw new Error('Your data changed while saving, three times running. Nothing was written — try again.')
257
+ }
package/src/tools.js ADDED
@@ -0,0 +1,242 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // TOOLS — shaped like the sentences people say, not like the database.
3
+ //
4
+ // The lazy design exposes get_state / set_state. That makes the model read a
5
+ // few hundred KB to add one task, and hands it the whole blob to write back.
6
+ // Every tool here takes a small payload and returns only what changed.
7
+ //
8
+ // Safety, deliberately baked in rather than prompted for:
9
+ // · nothing destructive — no delete, no clear, no reset. The worst a bad
10
+ // call can do is add a task you remove, or tick one you untick.
11
+ // · hard caps per call, so a model looping on a bad plan can't write 400 rows.
12
+ // · everything written carries source:'mcp' so the app can show it and you
13
+ // can undo a batch.
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ import { readState, mutate } from './store.js'
17
+
18
+ const MAX_ADD = 10
19
+ const uid = () => Math.random().toString(36).slice(2, 11)
20
+ const today = () => {
21
+ const d = new Date()
22
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
23
+ }
24
+
25
+ const arr = (v) => (Array.isArray(v) ? v : [])
26
+
27
+ /** Match a task the way a person refers to it: by substring, not by id. */
28
+ function findTask(list, ref) {
29
+ const q = String(ref || '').trim().toLowerCase()
30
+ if (!q) return null
31
+ const open = list.filter(t => !t.done)
32
+ const pools = [open, list]
33
+ for (const pool of pools) {
34
+ const exact = pool.find(t => (t.text || '').toLowerCase() === q)
35
+ if (exact) return exact
36
+ const starts = pool.find(t => (t.text || '').toLowerCase().startsWith(q))
37
+ if (starts) return starts
38
+ const has = pool.filter(t => (t.text || '').toLowerCase().includes(q))
39
+ if (has.length === 1) return has[0]
40
+ if (has.length > 1) return { ambiguous: has.map(t => t.text) }
41
+ }
42
+ return null
43
+ }
44
+
45
+ export const TOOLS = [
46
+ {
47
+ name: 'today',
48
+ title: "Today's plan",
49
+ description:
50
+ "What TASK//SWIPE has for today: open and completed tasks, the core task, "
51
+ + "the day score if posted, and today's timed blocks. Call this before "
52
+ + "answering anything about what the user is doing today.",
53
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
54
+ async run() {
55
+ const { state } = await readState()
56
+ const tasks = arr(state.todayTasks)
57
+ const score = state.dayScores?.[today()]
58
+ return {
59
+ date: today(),
60
+ core_task: state.coreTask?.task || state.coreTask || null,
61
+ open: tasks.filter(t => !t.done).map(t => ({ text: t.text, at: t.at || null, category: t.category || null })),
62
+ done: tasks.filter(t => t.done).map(t => t.text),
63
+ day_score: score ? { score: score.score, of: 10 } : null,
64
+ counts: { open: tasks.filter(t => !t.done).length, done: tasks.filter(t => t.done).length },
65
+ }
66
+ },
67
+ },
68
+
69
+ {
70
+ name: 'add_tasks',
71
+ title: 'Add tasks to today',
72
+ description:
73
+ 'Add one or more tasks to today\'s list. Batch them in a single call — '
74
+ + 'people add several at once. Maximum 10 per call. Skips exact duplicates.',
75
+ inputSchema: {
76
+ type: 'object',
77
+ properties: {
78
+ tasks: {
79
+ type: 'array', minItems: 1, maxItems: MAX_ADD,
80
+ items: { type: 'string', minLength: 1, maxLength: 200 },
81
+ description: 'Task descriptions, as the user said them.',
82
+ },
83
+ category: {
84
+ type: 'string',
85
+ enum: ['work', 'health', 'admin', 'social', 'projects', 'personal', 'mindset', 'learning'],
86
+ description: 'Optional category for all of them.',
87
+ },
88
+ },
89
+ required: ['tasks'], additionalProperties: false,
90
+ },
91
+ async run({ tasks, category }) {
92
+ const all = tasks.map(t => String(t).trim()).filter(Boolean)
93
+ const wanted = all.slice(0, MAX_ADD)
94
+ // Silently dropping the overflow would have the model believe it added
95
+ // all twelve. Say what didn't make it so it can add the rest.
96
+ const dropped = all.slice(MAX_ADD)
97
+ let added = [], skipped = []
98
+ await mutate(state => {
99
+ const list = arr(state.todayTasks)
100
+ const have = new Set(list.map(t => (t.text || '').toLowerCase()))
101
+ const fresh = []
102
+ for (const text of wanted) {
103
+ if (have.has(text.toLowerCase())) { skipped.push(text); continue }
104
+ have.add(text.toLowerCase())
105
+ fresh.push({
106
+ id: uid(), text, done: false, createdDate: today(),
107
+ category: category || 'personal',
108
+ source: 'mcp', // attributed, so the app can show + undo it
109
+ })
110
+ }
111
+ added = fresh.map(t => t.text)
112
+ if (!fresh.length) return null
113
+ return { ...state, todayTasks: [...list, ...fresh] }
114
+ })
115
+ return {
116
+ added, skipped,
117
+ not_added_over_limit: dropped,
118
+ message: `Added ${added.length} to today`
119
+ + (skipped.length ? `, skipped ${skipped.length} already there` : '')
120
+ + (dropped.length ? `. ${dropped.length} were over the ${MAX_ADD}-per-call limit and were NOT added — call again for those.` : '.'),
121
+ }
122
+ },
123
+ },
124
+
125
+ {
126
+ name: 'complete_task',
127
+ title: 'Tick a task off',
128
+ description:
129
+ 'Mark a task on today\'s list as done. Refer to it however the user did — '
130
+ + 'a few words is enough, no id needed. Returns the exact task matched.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: { task: { type: 'string', minLength: 1, description: 'Any part of the task text.' } },
134
+ required: ['task'], additionalProperties: false,
135
+ },
136
+ async run({ task }) {
137
+ let result = null
138
+ await mutate(state => {
139
+ const list = arr(state.todayTasks)
140
+ const hit = findTask(list, task)
141
+ if (!hit) { result = { ok: false, reason: 'no_match' }; return null }
142
+ if (hit.ambiguous) { result = { ok: false, reason: 'ambiguous', candidates: hit.ambiguous }; return null }
143
+ if (hit.done) { result = { ok: true, already: true, task: hit.text }; return null }
144
+ result = { ok: true, task: hit.text }
145
+ return {
146
+ ...state,
147
+ todayTasks: list.map(t => (t.id === hit.id ? { ...t, done: true, completedAt: Date.now() } : t)),
148
+ }
149
+ })
150
+ if (!result?.ok && result?.reason === 'no_match') {
151
+ return { ok: false, message: `Nothing on today's list matches "${task}".` }
152
+ }
153
+ if (result?.reason === 'ambiguous') {
154
+ return { ok: false, message: `That matches several: ${result.candidates.join(', ')}. Be more specific.` }
155
+ }
156
+ return { ok: true, message: result.already ? `"${result.task}" was already done.` : `Ticked off "${result.task}".` }
157
+ },
158
+ },
159
+
160
+ {
161
+ name: 'capture',
162
+ title: 'Capture an idea',
163
+ description:
164
+ 'Park a thought as an idea — NOT a task. Use this when the user says '
165
+ + '"remember this", "note that down", or floats something they might do '
166
+ + 'later. Use add_tasks when it is actually something to do today.',
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: { text: { type: 'string', minLength: 1, maxLength: 500 } },
170
+ required: ['text'], additionalProperties: false,
171
+ },
172
+ async run({ text }) {
173
+ const clean = String(text).trim()
174
+ await mutate(state => ({
175
+ ...state,
176
+ ideas: [{ id: uid(), text: clean, createdAt: Date.now(), source: 'mcp' }, ...arr(state.ideas)],
177
+ }))
178
+ return { ok: true, message: `Captured: "${clean}"` }
179
+ },
180
+ },
181
+
182
+ {
183
+ name: 'set_core_task',
184
+ title: 'Set the core task',
185
+ description:
186
+ "Set the ONE thing that would make today count. There is exactly one, so "
187
+ + 'this replaces any existing core task.',
188
+ inputSchema: {
189
+ type: 'object',
190
+ properties: { task: { type: 'string', minLength: 1, maxLength: 200 } },
191
+ required: ['task'], additionalProperties: false,
192
+ },
193
+ async run({ task }) {
194
+ const clean = String(task).trim()
195
+ let previous = null
196
+ await mutate(state => {
197
+ previous = state.coreTask?.task || state.coreTask || null
198
+ return { ...state, coreTask: { task: clean, setAt: Date.now(), source: 'mcp' } }
199
+ })
200
+ return { ok: true, core_task: clean, replaced: previous, message: `Core task set to "${clean}".` }
201
+ },
202
+ },
203
+
204
+ {
205
+ name: 'search',
206
+ title: 'Search everything',
207
+ description:
208
+ 'Search across tasks, ideas, journal entries, lists and the backlog. '
209
+ + 'Use when the user asks whether they wrote something down, or what they '
210
+ + 'were doing about a topic.',
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ query: { type: 'string', minLength: 2 },
215
+ limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 },
216
+ },
217
+ required: ['query'], additionalProperties: false,
218
+ },
219
+ async run({ query, limit = 20 }) {
220
+ const { state } = await readState()
221
+ const q = String(query).toLowerCase()
222
+ const hits = []
223
+ const add = (where, text, extra = {}) => {
224
+ if (text && String(text).toLowerCase().includes(q)) hits.push({ where, text: String(text), ...extra })
225
+ }
226
+ arr(state.todayTasks).forEach(t => add('today', t.text, { done: !!t.done }))
227
+ arr(state.backlogTasks).forEach(t => add('backlog', t.text))
228
+ arr(state.taskDatabase).forEach(t => add('database', t.text))
229
+ arr(state.ideas).forEach(i => add('ideas', i.text))
230
+ arr(state.decompress).forEach(d => add('decompress', d.text))
231
+ Object.entries(state.journal || {}).forEach(([date, entries]) =>
232
+ arr(entries).forEach(e => add('journal', e?.text, { date })))
233
+ arr(state.lists).forEach(l => arr(l.items).forEach(i => add(`list:${l.title || l.name}`, i?.text ?? i)))
234
+ Object.entries(state.plannedTasks || {}).forEach(([date, list]) =>
235
+ arr(list).forEach(t => add('planned', t.text, { date })))
236
+
237
+ return { query, count: hits.length, results: hits.slice(0, limit) }
238
+ },
239
+ },
240
+ ]
241
+
242
+ export const byName = Object.fromEntries(TOOLS.map(t => [t.name, t]))