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 +175 -0
- package/package.json +19 -0
- package/src/index.js +61 -0
- package/src/store.js +257 -0
- package/src/tools.js +242 -0
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]))
|