taskswipe-mcp 0.1.0 → 0.2.1
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 +51 -10
- package/package.json +2 -2
- package/src/brief.js +384 -0
- package/src/index.js +10 -5
- package/src/ob1.js +104 -0
- package/src/os.js +367 -0
- package/src/setup.js +182 -0
- package/src/store.js +63 -20
- package/src/tools.js +361 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# taskswipe-mcp
|
|
2
2
|
|
|
3
|
-
Your
|
|
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
|
|
@@ -27,15 +27,14 @@ token over the wire.
|
|
|
27
27
|
|
|
28
28
|
## Setup
|
|
29
29
|
|
|
30
|
-
You need your
|
|
30
|
+
You need your TASKKA login token — that's it. The production Supabase URL
|
|
31
31
|
and anon key are baked in as defaults (they're public — they ship in the web
|
|
32
32
|
app's JS bundle); self-hosters can override them with `TASKSWIPE_SUPABASE_URL`
|
|
33
33
|
and `TASKSWIPE_SUPABASE_ANON_KEY`.
|
|
34
34
|
|
|
35
|
-
> **
|
|
36
|
-
>
|
|
37
|
-
>
|
|
38
|
-
> Once it's published, replace that with `npx -y taskswipe-mcp` everywhere.
|
|
35
|
+
> **Published:** https://www.npmjs.com/package/taskswipe-mcp — so anywhere the
|
|
36
|
+
> examples below run it by path, `npx -y taskswipe-mcp` works instead and needs
|
|
37
|
+
> nothing checked out.
|
|
39
38
|
|
|
40
39
|
**Claude Code**
|
|
41
40
|
|
|
@@ -74,6 +73,50 @@ TASKSWIPE_ACCESS_TOKEN = "eyJ..."
|
|
|
74
73
|
(`codex mcp list` to confirm it registered; note the section is
|
|
75
74
|
`mcp_servers`, snake_case, not `mcpServers`.)
|
|
76
75
|
|
|
76
|
+
**Hermes Agent** (Nous) — local stdio, tokens refresh themselves. Paste into `~/.hermes/config.yaml`, then install the skill:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
mcp_servers:
|
|
80
|
+
taskka:
|
|
81
|
+
command: "npx"
|
|
82
|
+
args: ["-y", "taskswipe-mcp"]
|
|
83
|
+
env:
|
|
84
|
+
TASKSWIPE_ACCESS_TOKEN: "eyJ..."
|
|
85
|
+
TASKSWIPE_REFRESH_TOKEN: "..."
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
hermes skills install https://taskka.app/.well-known/skills/taskka/SKILL.md
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Copy both snippets from TASKKA → SETTINGS → AGENTS (signed in) so the tokens are live.
|
|
93
|
+
|
|
94
|
+
**OpenClaw** — same local server. Paste into `openclaw.json` (or Control UI → Settings → MCP):
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcp": {
|
|
99
|
+
"servers": {
|
|
100
|
+
"taskka": {
|
|
101
|
+
"command": "npx",
|
|
102
|
+
"args": ["-y", "taskswipe-mcp"],
|
|
103
|
+
"env": {
|
|
104
|
+
"TASKSWIPE_ACCESS_TOKEN": "eyJ...",
|
|
105
|
+
"TASKSWIPE_REFRESH_TOKEN": "..."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
openclaw skills install https://taskka.app/.well-known/skills/taskka/SKILL.md
|
|
115
|
+
openclaw mcp doctor taskka --probe
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Hosted HTTP (`https://taskka.app/mcp` + `Authorization: Bearer <access>`) works for a Control-UI one-shot. Access tokens die in about an hour — always-on Hermes/OpenClaw gateways should use stdio above.
|
|
119
|
+
|
|
77
120
|
**Claude Desktop / Cursor** — these do use the JSON shape
|
|
78
121
|
(`claude_desktop_config.json` / `.cursor/mcp.json`):
|
|
79
122
|
|
|
@@ -95,7 +138,7 @@ TASKSWIPE_ACCESS_TOKEN = "eyJ..."
|
|
|
95
138
|
|
|
96
139
|
### Getting an access token
|
|
97
140
|
|
|
98
|
-
Sign in to
|
|
141
|
+
Sign in to TASKKA in a browser, open the console, and run:
|
|
99
142
|
|
|
100
143
|
```js
|
|
101
144
|
JSON.parse(Object.entries(localStorage).find(([k]) => k.includes('auth-token'))[1]).access_token
|
|
@@ -129,9 +172,7 @@ The repo root carries `.codex-plugin/plugin.json` + `.mcp.json`, which makes
|
|
|
129
172
|
the whole repo an installable Codex plugin. The path to being listed
|
|
130
173
|
(as of 2026-08):
|
|
131
174
|
|
|
132
|
-
1.
|
|
133
|
-
`npx -y taskswipe-mcp`, which is dead until the package exists.
|
|
134
|
-
`cd mcp && npm publish --access public` under an npm account.
|
|
175
|
+
1. ~~Publish to npm~~ — **done**, `taskswipe-mcp@0.1.0`.
|
|
135
176
|
2. **Interim listing** — submit the GitHub repo URL to the community registry
|
|
136
177
|
at codex-marketplace.com (automated review of the manifest).
|
|
137
178
|
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
|
|
4
|
-
"description": "Read and write your
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Read and write your TASKKA day from Claude Code, Claude Desktop, 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,384 @@
|
|
|
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) {
|
|
91
|
+
attention.push({ area: 'dropped', severity: missed.length >= 3 ? 2 : 1,
|
|
92
|
+
what: `${missed.length} unfinished from earlier days sitting in LOG → DROPPED`,
|
|
93
|
+
items: missed.slice(0, 4).map(t => t.text) })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Habits are the app's committed tier: quiet ones are the real signal.
|
|
97
|
+
const quietHabits = arr(S.habits)
|
|
98
|
+
.map(h => ({ name: h.name || h.title, hits: hitRate(habitDoneDays(h), 7, now) }))
|
|
99
|
+
.filter(h => h.hits === 0)
|
|
100
|
+
if (quietHabits.length) {
|
|
101
|
+
attention.push({ area: 'habits', severity: 2,
|
|
102
|
+
what: `${quietHabits.length} habit${quietHabits.length === 1 ? '' : 's'} untouched for a week`,
|
|
103
|
+
items: quietHabits.slice(0, 4).map(h => h.name) })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const decay = S.decayState || {}
|
|
107
|
+
if (num(decay.quietStreak) >= 2) {
|
|
108
|
+
attention.push({ area: 'squares', severity: 2,
|
|
109
|
+
what: `${decay.quietStreak} quiet days in a row — squares are decaying` })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!S.coreTask && open.length > 3) {
|
|
113
|
+
attention.push({ area: 'today', severity: 1,
|
|
114
|
+
what: `${open.length} things open and nothing named as the one that matters` })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
attention.sort((a, b) => b.severity - a.severity)
|
|
118
|
+
|
|
119
|
+
// ── AREAS: one line each, including the empty ones ────────────────────────
|
|
120
|
+
const foodToday = arr(S.foodLog?.[today])
|
|
121
|
+
const kcal = Math.round(foodToday.reduce((n, e) =>
|
|
122
|
+
n + (num(e.per100?.kcal) * num(e.grams)) / 100, 0))
|
|
123
|
+
const health = S.healthDaily?.[today] || {}
|
|
124
|
+
|
|
125
|
+
const stacks = arr(S.stacks).filter(s => !s.retiredAt).map(s => ({
|
|
126
|
+
name: s.name,
|
|
127
|
+
hits: hitRate(stackDoneDays(s), Math.round((num(s.graduationTarget) || 14) * 1.5), now),
|
|
128
|
+
target: num(s.graduationTarget) || 14,
|
|
129
|
+
}))
|
|
130
|
+
const readyStacks = stacks.filter(s => s.hits >= s.target)
|
|
131
|
+
|
|
132
|
+
const goalFrames = S.goals && typeof S.goals === 'object' ? Object.values(S.goals).flat() : []
|
|
133
|
+
const building = goalFrames.find(g => g?.id === S.activeGoalId)
|
|
134
|
+
|
|
135
|
+
const maps = arr(S.studyMaps)
|
|
136
|
+
const learningTopics = maps.flatMap(m => arr(m.nodes).filter(n => n.status === 'learning')
|
|
137
|
+
.map(n => `${m.title}: ${n.title}`))
|
|
138
|
+
|
|
139
|
+
const biz = arr(S.businessIdeas)
|
|
140
|
+
const areas = {
|
|
141
|
+
today: `${open.length} open, ${done.length} done${S.coreTask ? `, core: ${S.coreTask.task || S.coreTask}` : ''}`,
|
|
142
|
+
dropped: missed.length
|
|
143
|
+
? `${missed.length} unfinished from earlier days (LOG → DROPPED)`
|
|
144
|
+
: 'nothing sitting in DROPPED',
|
|
145
|
+
backlog: `${arr(S.backlogTasks).length} parked`,
|
|
146
|
+
docket: `${arr(S.docketTasks).length} coming-up with no clock`,
|
|
147
|
+
deck: `${arr(S.deck).length} cards waiting to be swiped`,
|
|
148
|
+
recurring: `${arr(S.recurringTasks).length} rules`,
|
|
149
|
+
lists: `${arr(S.lists).length} lists`,
|
|
150
|
+
frame: `${arr(S.frameworks).length} living frameworks`,
|
|
151
|
+
business: biz.length
|
|
152
|
+
? `${biz.filter(i => i?.stage !== 'ship' && i?.stage !== 'drop').length} live ideas on the spark-to-ship path`
|
|
153
|
+
: 'no ideas on the path',
|
|
154
|
+
people: `${arr(S.people).length} people`,
|
|
155
|
+
things: `${arr(S.things).length} kept`,
|
|
156
|
+
missions: `${arr(S.missions).filter(m => !m.done).length} open`,
|
|
157
|
+
goals: goalFrames.length
|
|
158
|
+
? `${goalFrames.length} goals${building ? `, building: ${building.text}` : ', none on the crane'}`
|
|
159
|
+
: 'none set',
|
|
160
|
+
habits: arr(S.habits).length
|
|
161
|
+
? `${arr(S.habits).length} habits, ${arr(S.habits).filter(h => habitDoneDays(h).has(today)).length} ticked today`
|
|
162
|
+
: 'none',
|
|
163
|
+
stacks: stacks.length
|
|
164
|
+
? `${stacks.length} building${readyStacks.length ? `, ${readyStacks.length} ready to graduate` : ''}`
|
|
165
|
+
: 'none',
|
|
166
|
+
food: foodToday.length
|
|
167
|
+
? `${kcal} kcal logged today, ${arr(S.pantry).length} foods on the shelf`
|
|
168
|
+
: `nothing logged today, ${arr(S.pantry).length} foods on the shelf`,
|
|
169
|
+
lift: `${arr(S.liftSessions).length} sessions logged`,
|
|
170
|
+
walk: health.steps ? `${health.steps} steps today` : 'no step data today',
|
|
171
|
+
money: arr(S.moneyTx).length ? `${arr(S.moneyTx).length} transactions` : 'nothing tracked',
|
|
172
|
+
mind: S.moods?.[today]?.length
|
|
173
|
+
? `${S.moods[today].length} check-ins today`
|
|
174
|
+
: 'no check-in today',
|
|
175
|
+
day_score: S.dayScores?.[today] ? `${S.dayScores[today].score}/10 posted` : 'not posted',
|
|
176
|
+
study: maps.length
|
|
177
|
+
? `${maps.length} maps, ${learningTopics.length} topics in progress`
|
|
178
|
+
: 'none',
|
|
179
|
+
reader: `${arr(S.books).length} books`,
|
|
180
|
+
parley: `${arr(S.parleyDeck).length} cards`,
|
|
181
|
+
squares: `${num(S.totalSquares)} total${num(decay.quietStreak) ? `, ${decay.quietStreak} quiet days` : ''}`,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
now: { date: today, time: hhmm, part_of_day: partOfDay(now.getHours()) },
|
|
186
|
+
|
|
187
|
+
// The one sentence an agent should lead with. Everything below is evidence.
|
|
188
|
+
headline: attention[0]?.what
|
|
189
|
+
|| (open.length ? `${open.length} open today, nothing overdue` : 'nothing open today'),
|
|
190
|
+
|
|
191
|
+
today: {
|
|
192
|
+
core_task: S.coreTask?.task || S.coreTask || null,
|
|
193
|
+
open: open.length,
|
|
194
|
+
done: done.length,
|
|
195
|
+
// Only the ones with a time — an untimed list of forty is not a schedule.
|
|
196
|
+
next: timed.filter(t => String(t.at) >= hhmm).slice(0, 3).map(t => `${t.at} ${t.text}`),
|
|
197
|
+
overdue: overdue.slice(0, 5).map(t => `${t.at} ${t.text}`),
|
|
198
|
+
open_titles: open.slice(0, 12).map(t => t.text),
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
attention: attention.slice(0, 6),
|
|
202
|
+
areas,
|
|
203
|
+
|
|
204
|
+
// Where to look next, so an agent does not have to guess which tools exist.
|
|
205
|
+
drill: {
|
|
206
|
+
more_on_an_area: "detail(area) — any key from `areas`",
|
|
207
|
+
today_in_full: 'today()',
|
|
208
|
+
dropped_in_full: 'detail({ area: "dropped" })',
|
|
209
|
+
find_anything: 'search(query)',
|
|
210
|
+
write: 'add_tasks, complete_task, capture, set_core_task, recover_dropped',
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
// DETAIL — one area, still summarised.
|
|
217
|
+
//
|
|
218
|
+
// The point of splitting this out is that an agent pays for depth only where it
|
|
219
|
+
// asked for it. Each branch returns the shape someone would actually reason
|
|
220
|
+
// with, not the rows.
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export function buildDetail(state = {}, area = '', now = new Date()) {
|
|
224
|
+
const S = state || {}
|
|
225
|
+
const today = dayStr(now)
|
|
226
|
+
const key = String(area || '').toLowerCase().trim()
|
|
227
|
+
|
|
228
|
+
switch (key) {
|
|
229
|
+
case 'today': {
|
|
230
|
+
const tasks = arr(S.todayTasks)
|
|
231
|
+
return {
|
|
232
|
+
open: tasks.filter(t => !t.done).map(t => ({
|
|
233
|
+
text: t.text, at: t.at || null, category: t.category || null,
|
|
234
|
+
why: t.why || null, timer: t.timer || null, sticky: !!t.sticky,
|
|
235
|
+
})),
|
|
236
|
+
done: tasks.filter(t => t.done).map(t => t.text),
|
|
237
|
+
dropped: arr(S.missedTasks).slice(0, 20).map(t => t.text),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
case 'dropped':
|
|
241
|
+
case 'log':
|
|
242
|
+
return {
|
|
243
|
+
where: 'LOG → DROPPED. Tasks that aged out unfinished from earlier days. Not the backlog, not CUT.',
|
|
244
|
+
count: arr(S.missedTasks).length,
|
|
245
|
+
items: arr(S.missedTasks).slice(0, 40).map(t => ({
|
|
246
|
+
text: t.text,
|
|
247
|
+
expired: t.expiredDate || null,
|
|
248
|
+
category: t.category || null,
|
|
249
|
+
})),
|
|
250
|
+
recover: 'recover_dropped({ task }) puts one back on today',
|
|
251
|
+
}
|
|
252
|
+
case 'docket':
|
|
253
|
+
case 'calendar':
|
|
254
|
+
return {
|
|
255
|
+
docket: arr(S.docketTasks).slice(0, 40).map(t => t.text || t.task),
|
|
256
|
+
planned_days: Object.keys(S.plannedTasks || {}).length,
|
|
257
|
+
}
|
|
258
|
+
case 'lists':
|
|
259
|
+
return arr(S.lists).slice(0, 30).map(l => ({
|
|
260
|
+
name: l.title || l.name, items: arr(l.items).length,
|
|
261
|
+
}))
|
|
262
|
+
case 'frame':
|
|
263
|
+
return arr(S.frameworks).slice(0, 20).map(f => ({
|
|
264
|
+
name: f.name, when: f.when || null, decisions: arr(f.decisions).length,
|
|
265
|
+
}))
|
|
266
|
+
case 'business':
|
|
267
|
+
return arr(S.businessIdeas).slice(0, 40).map(i => ({
|
|
268
|
+
title: i.title, stage: i.stage || 'spark', next: i.next || null,
|
|
269
|
+
}))
|
|
270
|
+
case 'people':
|
|
271
|
+
return arr(S.people).slice(0, 40).map(p => ({
|
|
272
|
+
name: p.name, role: p.role || null, company: p.company || null,
|
|
273
|
+
}))
|
|
274
|
+
case 'things':
|
|
275
|
+
return { kept: arr(S.things).length, categories: arr(S.thingsCats).length }
|
|
276
|
+
case 'habits':
|
|
277
|
+
return arr(S.habits).map(h => {
|
|
278
|
+
const days = habitDoneDays(h)
|
|
279
|
+
return { name: h.name || h.title, today: days.has(today), last_7: hitRate(days, 7, now), lifetime: days.size }
|
|
280
|
+
})
|
|
281
|
+
case 'stacks':
|
|
282
|
+
return arr(S.stacks).map(s => {
|
|
283
|
+
const days = stackDoneDays(s)
|
|
284
|
+
const target = num(s.graduationTarget) || 14
|
|
285
|
+
const window = Math.round(target * 1.5)
|
|
286
|
+
return {
|
|
287
|
+
name: s.name, cue: s.anchor || null, minimum: s.minimum || null,
|
|
288
|
+
retired: !!s.retiredAt,
|
|
289
|
+
hits: hitRate(days, window, now), target, window,
|
|
290
|
+
ready: hitRate(days, window, now) >= target,
|
|
291
|
+
today: days.has(today),
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
case 'goals': {
|
|
295
|
+
const frames = S.goals && typeof S.goals === 'object' ? S.goals : {}
|
|
296
|
+
return Object.entries(frames).flatMap(([frame, list]) => arr(list).map(g => ({
|
|
297
|
+
frame, text: g.text, bricks: `${num(g.earnedBricks)}/${num(g.targetBricks) || 20}`,
|
|
298
|
+
building: g.id === S.activeGoalId,
|
|
299
|
+
steps: arr(g.steps).length ? arr(g.steps).map(st => `${st.stage}: ${st.text}`) : null,
|
|
300
|
+
})))
|
|
301
|
+
}
|
|
302
|
+
case 'food': {
|
|
303
|
+
const day = arr(S.foodLog?.[today])
|
|
304
|
+
const shopping = arr(S.foodShoppingList)
|
|
305
|
+
const openShopping = shopping.filter(row => !row.done)
|
|
306
|
+
const tot = day.reduce((a, e) => {
|
|
307
|
+
const f = num(e.grams) / 100
|
|
308
|
+
return {
|
|
309
|
+
kcal: a.kcal + num(e.per100?.kcal) * f,
|
|
310
|
+
protein: a.protein + num(e.per100?.protein) * f,
|
|
311
|
+
carbs: a.carbs + num(e.per100?.carbs) * f,
|
|
312
|
+
fat: a.fat + num(e.per100?.fat) * f,
|
|
313
|
+
}
|
|
314
|
+
}, { kcal: 0, protein: 0, carbs: 0, fat: 0 })
|
|
315
|
+
return {
|
|
316
|
+
today: day.map(e => `${e.meal}: ${e.name} ${e.grams}${e.unit || 'g'}`),
|
|
317
|
+
totals: Object.fromEntries(Object.entries(tot).map(([k, v]) => [k, Math.round(v)])),
|
|
318
|
+
targets: S.foodTargets || null,
|
|
319
|
+
pantry_size: arr(S.pantry).length,
|
|
320
|
+
pantry_catalog_size: arr(S.pantry).length,
|
|
321
|
+
pantry_stock: arr(S.pantryStock).slice(0, 100).map(row => ({
|
|
322
|
+
name: row.name,
|
|
323
|
+
quantity: num(row.quantity),
|
|
324
|
+
unit: row.unit || 'each',
|
|
325
|
+
location: row.location || null,
|
|
326
|
+
expires_on: row.expiresOn || null,
|
|
327
|
+
minimum: num(row.minimum),
|
|
328
|
+
})),
|
|
329
|
+
shopping_list: openShopping.slice(0, 60).map(row => ({
|
|
330
|
+
name: row.name,
|
|
331
|
+
quantity: num(row.quantity) || 1,
|
|
332
|
+
unit: row.unit || 'each',
|
|
333
|
+
source: row.source || 'manual',
|
|
334
|
+
})),
|
|
335
|
+
shopping_counts: {
|
|
336
|
+
open: openShopping.length,
|
|
337
|
+
bought: shopping.length - openShopping.length,
|
|
338
|
+
returned: Math.min(openShopping.length, 60),
|
|
339
|
+
},
|
|
340
|
+
ordering_rules: String(S.aiPlaybookOverlays?.food || '').slice(0, 4000) || null,
|
|
341
|
+
saved_meals: arr(S.meals).map(m => m.name),
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
case 'study':
|
|
345
|
+
return arr(S.studyMaps).map(m => ({
|
|
346
|
+
map: m.title, aim: m.blurb || null,
|
|
347
|
+
topics: arr(m.nodes).map(n => ({
|
|
348
|
+
title: n.title, status: n.status,
|
|
349
|
+
// The tutor's note is the memory of this topic — the single most
|
|
350
|
+
// useful thing an agent can read about someone's learning.
|
|
351
|
+
next: (n.note || '').split(/^##\s+Next\s*$/mi)[1]?.split('\n').map(l => l.trim()).find(Boolean) || null,
|
|
352
|
+
})),
|
|
353
|
+
}))
|
|
354
|
+
case 'lift':
|
|
355
|
+
return {
|
|
356
|
+
sessions: arr(S.liftSessions).slice(-8).map(s => ({
|
|
357
|
+
date: s.date, name: s.name || null,
|
|
358
|
+
exercises: arr(s.exercises).map(e => e.name).slice(0, 12),
|
|
359
|
+
})),
|
|
360
|
+
routines: arr(S.liftRoutines).map(r => r.name),
|
|
361
|
+
}
|
|
362
|
+
case 'mind': {
|
|
363
|
+
// The day note is composed of tagged segments; an agent wants the prose.
|
|
364
|
+
const segs = arr(S.moodDayJournals?.[today])
|
|
365
|
+
return {
|
|
366
|
+
today: arr(S.moods?.[today]).map(m => m.mood || m.key),
|
|
367
|
+
day_score: S.dayScores?.[today] || null,
|
|
368
|
+
note: segs.map(x => x.text).filter(Boolean).join(' ').slice(0, 800) || null,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
case 'squares':
|
|
372
|
+
return {
|
|
373
|
+
total: num(S.totalSquares),
|
|
374
|
+
decay: S.decayState || null,
|
|
375
|
+
recent_losses: arr(S.decayLog).slice(-5),
|
|
376
|
+
}
|
|
377
|
+
case 'backlog':
|
|
378
|
+
return arr(S.backlogTasks).slice(0, 40).map(t => ({ text: t.text, horizon: t.horizon || null }))
|
|
379
|
+
case 'missions':
|
|
380
|
+
return arr(S.missions).map(m => ({ title: m.title, done: !!m.done, tasks: arr(m.taskIds).length }))
|
|
381
|
+
default:
|
|
382
|
+
return { error: `unknown area "${area}"`, known: Object.keys(buildBrief(S, now).areas) }
|
|
383
|
+
}
|
|
384
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
-
//
|
|
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
|
|
@@ -51,11 +51,16 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
51
51
|
log(`[taskswipe] ${tool.name} failed:`, e?.message || e)
|
|
52
52
|
return {
|
|
53
53
|
isError: true,
|
|
54
|
-
content: [{ type: 'text', text: `
|
|
54
|
+
content: [{ type: 'text', text: `TASKKA: ${e?.message || 'unknown error'}` }],
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
await
|
|
61
|
-
|
|
59
|
+
if (process.argv[2] === 'setup') {
|
|
60
|
+
const { runSetup } = await import('./setup.js')
|
|
61
|
+
await runSetup(process.argv.slice(3))
|
|
62
|
+
} else {
|
|
63
|
+
const transport = new StdioServerTransport()
|
|
64
|
+
await server.connect(transport)
|
|
65
|
+
log('[taskswipe] MCP server ready on stdio —', TOOLS.length, 'tools')
|
|
66
|
+
}
|
package/src/ob1.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// OB1 / Open Brain — durable cross-tool memory (Supabase + MCP).
|
|
2
|
+
// TASKKA talks to the same HTTP MCP Codex and Grok already use.
|
|
3
|
+
// Do not dump the vault into it. Capture a concise lesson, not a file.
|
|
4
|
+
|
|
5
|
+
import { looksLikeSecret } from './os.js'
|
|
6
|
+
|
|
7
|
+
const MAX_CAPTURE = 4000
|
|
8
|
+
|
|
9
|
+
function endpoint() {
|
|
10
|
+
const raw = String(process.env.OPEN_BRAIN_URL || process.env.OB1_MCP_URL || '').trim()
|
|
11
|
+
if (!raw) return ''
|
|
12
|
+
const key = String(process.env.OPEN_BRAIN_KEY || process.env.OB1_KEY || '').trim()
|
|
13
|
+
if (!key || /[?&]key=/.test(raw)) return raw
|
|
14
|
+
return raw.includes('?') ? `${raw}&key=${encodeURIComponent(key)}` : `${raw}?key=${encodeURIComponent(key)}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ob1Configured() {
|
|
18
|
+
return !!endpoint()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseMcpBody(text) {
|
|
22
|
+
const src = String(text || '').trim()
|
|
23
|
+
if (!src) return {}
|
|
24
|
+
if (src.startsWith('{')) {
|
|
25
|
+
try { return JSON.parse(src) } catch { /* SSE below */ }
|
|
26
|
+
}
|
|
27
|
+
for (const block of src.split('\n\n')) {
|
|
28
|
+
const line = block.split('\n').find(l => l.startsWith('data: '))
|
|
29
|
+
if (!line) continue
|
|
30
|
+
try { return JSON.parse(line.slice(6)) } catch { /* next */ }
|
|
31
|
+
}
|
|
32
|
+
return { error: { message: 'ob1_unreadable' } }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function mcpCall(name, args = {}) {
|
|
36
|
+
const url = endpoint()
|
|
37
|
+
if (!url) throw Object.assign(new Error('ob1_unconfigured'), { status: 503 })
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
Accept: 'application/json, text/event-stream',
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
jsonrpc: '2.0',
|
|
46
|
+
id: Date.now(),
|
|
47
|
+
method: 'tools/call',
|
|
48
|
+
params: { name, arguments: args },
|
|
49
|
+
}),
|
|
50
|
+
})
|
|
51
|
+
const raw = await res.text()
|
|
52
|
+
const msg = parseMcpBody(raw)
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw Object.assign(new Error(msg.error?.message || `ob1_${res.status}`), { status: res.status === 401 ? 403 : 502 })
|
|
55
|
+
}
|
|
56
|
+
if (msg.error) {
|
|
57
|
+
throw Object.assign(new Error(msg.error.message || 'ob1_failed'), { status: 502 })
|
|
58
|
+
}
|
|
59
|
+
const text = (msg.result?.content || []).map(c => c.text || '').filter(Boolean).join('\n')
|
|
60
|
+
if (msg.result?.isError) {
|
|
61
|
+
throw Object.assign(new Error(text || 'ob1_tool_error'), { status: 502 })
|
|
62
|
+
}
|
|
63
|
+
return { text, result: msg.result }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatOb1Capture({ text, source, title } = {}) {
|
|
67
|
+
const body = String(text || '').trim()
|
|
68
|
+
const head = ['TASKKA', source, title].filter(Boolean).join(' · ')
|
|
69
|
+
const packed = head ? `${head}\n\n${body}` : body
|
|
70
|
+
return packed.slice(0, MAX_CAPTURE)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function ob1Status() {
|
|
74
|
+
if (!ob1Configured()) return { configured: false, thoughts: 0 }
|
|
75
|
+
try {
|
|
76
|
+
const { text } = await mcpCall('thought_stats', {})
|
|
77
|
+
const total = Number((text.match(/Total thoughts:\s*(\d+)/i) || [])[1] || 0)
|
|
78
|
+
return { configured: true, thoughts: total, summary: text.slice(0, 1200) }
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { configured: true, thoughts: 0, error: e.message || 'ob1_status_failed' }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function searchOb1(query, { limit = 8, threshold = 0.5 } = {}) {
|
|
85
|
+
const q = String(query || '').trim()
|
|
86
|
+
if (q.length < 2) return { query: q, results: [] }
|
|
87
|
+
const { text } = await mcpCall('search_thoughts', { query: q, limit, threshold })
|
|
88
|
+
return { query: q, text }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function listOb1({ limit = 8, days } = {}) {
|
|
92
|
+
const args = { limit }
|
|
93
|
+
if (days) args.days = days
|
|
94
|
+
const { text } = await mcpCall('list_thoughts', args)
|
|
95
|
+
return { text }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function captureOb1({ text, source, title } = {}) {
|
|
99
|
+
const content = formatOb1Capture({ text, source, title })
|
|
100
|
+
if (!content.trim()) throw Object.assign(new Error('empty_thought'), { status: 400 })
|
|
101
|
+
if (looksLikeSecret(content)) throw Object.assign(new Error('secrets_forbidden'), { status: 400 })
|
|
102
|
+
const { text: reply } = await mcpCall('capture_thought', { content })
|
|
103
|
+
return { ok: true, message: reply || 'Captured in OB1.' }
|
|
104
|
+
}
|