taskswipe-mcp 0.2.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 +22 -43
- package/package.json +2 -2
- package/src/brief.js +79 -5
- package/src/index.js +33 -42
- package/src/ob1.js +104 -0
- package/src/os.js +367 -0
- package/src/setup.js +7 -0
- package/src/store.js +67 -32
- package/src/tools.js +235 -4
package/README.md
CHANGED
|
@@ -25,36 +25,12 @@ row-level security still applies and no service key exists anywhere.
|
|
|
25
25
|
the server reading credentials from your own machine rather than sending a
|
|
26
26
|
token over the wire.
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Setup
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
the
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
npx -y taskswipe-mcp@latest setup hermes
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
or:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
npx -y taskswipe-mcp@latest setup openclaw
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
The installer asks for the TASKKA email and password. Password input is hidden;
|
|
44
|
-
the password is used once to mint a separate session for that VPS and is never
|
|
45
|
-
written to disk. The renewable session is saved at
|
|
46
|
-
`~/.taskswipe-mcp-session.json` with mode `0600`, then the installer registers
|
|
47
|
-
the stdio MCP, installs the TASKKA skill and runs the agent's connection check.
|
|
48
|
-
No tunnel, public port, YAML/JSON edit or hourly token copy is involved.
|
|
49
|
-
|
|
50
|
-
This is safer than copying the browser refresh token: the browser and VPS have
|
|
51
|
-
independent sessions and can rotate their credentials without racing each other.
|
|
52
|
-
|
|
53
|
-
## Manual setup and other clients
|
|
54
|
-
|
|
55
|
-
The production Supabase URL and anon key are baked in as defaults (they're
|
|
56
|
-
public — they ship in the web app's JS bundle); self-hosters can override them
|
|
57
|
-
with `TASKSWIPE_SUPABASE_URL` and `TASKSWIPE_SUPABASE_ANON_KEY`.
|
|
30
|
+
You need your TASKKA 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`.
|
|
58
34
|
|
|
59
35
|
> **Published:** https://www.npmjs.com/package/taskswipe-mcp — so anywhere the
|
|
60
36
|
> examples below run it by path, `npx -y taskswipe-mcp` works instead and needs
|
|
@@ -97,23 +73,25 @@ TASKSWIPE_ACCESS_TOKEN = "eyJ..."
|
|
|
97
73
|
(`codex mcp list` to confirm it registered; note the section is
|
|
98
74
|
`mcp_servers`, snake_case, not `mcpServers`.)
|
|
99
75
|
|
|
100
|
-
**Hermes Agent** (Nous) —
|
|
101
|
-
credential-free entry is equivalent to:
|
|
76
|
+
**Hermes Agent** (Nous) — local stdio, tokens refresh themselves. Paste into `~/.hermes/config.yaml`, then install the skill:
|
|
102
77
|
|
|
103
78
|
```yaml
|
|
104
79
|
mcp_servers:
|
|
105
80
|
taskka:
|
|
106
81
|
command: "npx"
|
|
107
82
|
args: ["-y", "taskswipe-mcp"]
|
|
83
|
+
env:
|
|
84
|
+
TASKSWIPE_ACCESS_TOKEN: "eyJ..."
|
|
85
|
+
TASKSWIPE_REFRESH_TOKEN: "..."
|
|
108
86
|
```
|
|
109
87
|
|
|
110
88
|
```bash
|
|
111
89
|
hermes skills install https://taskka.app/.well-known/skills/taskka/SKILL.md
|
|
112
90
|
```
|
|
113
91
|
|
|
114
|
-
|
|
92
|
+
Copy both snippets from TASKKA → SETTINGS → AGENTS (signed in) so the tokens are live.
|
|
115
93
|
|
|
116
|
-
**OpenClaw** —
|
|
94
|
+
**OpenClaw** — same local server. Paste into `openclaw.json` (or Control UI → Settings → MCP):
|
|
117
95
|
|
|
118
96
|
```json
|
|
119
97
|
{
|
|
@@ -121,7 +99,11 @@ The server reads the renewable VPS session from the owner-only session file.
|
|
|
121
99
|
"servers": {
|
|
122
100
|
"taskka": {
|
|
123
101
|
"command": "npx",
|
|
124
|
-
"args": ["-y", "taskswipe-mcp
|
|
102
|
+
"args": ["-y", "taskswipe-mcp"],
|
|
103
|
+
"env": {
|
|
104
|
+
"TASKSWIPE_ACCESS_TOKEN": "eyJ...",
|
|
105
|
+
"TASKSWIPE_REFRESH_TOKEN": "..."
|
|
106
|
+
}
|
|
125
107
|
}
|
|
126
108
|
}
|
|
127
109
|
}
|
|
@@ -133,9 +115,7 @@ openclaw skills install https://taskka.app/.well-known/skills/taskka/SKILL.md
|
|
|
133
115
|
openclaw mcp doctor taskka --probe
|
|
134
116
|
```
|
|
135
117
|
|
|
136
|
-
Hosted HTTP (`https://taskka.app/mcp` + `Authorization: Bearer <access>`) works
|
|
137
|
-
for a Control-UI one-shot. Access tokens die in about an hour — always-on
|
|
138
|
-
Hermes/OpenClaw gateways should use the one-time stdio bootstrap above.
|
|
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.
|
|
139
119
|
|
|
140
120
|
**Claude Desktop / Cursor** — these do use the JSON shape
|
|
141
121
|
(`claude_desktop_config.json` / `.cursor/mcp.json`):
|
|
@@ -166,9 +146,9 @@ JSON.parse(Object.entries(localStorage).find(([k]) => k.includes('auth-token'))[
|
|
|
166
146
|
|
|
167
147
|
It expires on its own, which is the point — a leaked token stops working.
|
|
168
148
|
|
|
169
|
-
###
|
|
149
|
+
### Making it permanent
|
|
170
150
|
|
|
171
|
-
|
|
151
|
+
Add `TASKSWIPE_REFRESH_TOKEN` next to it — same console snippet, ending in
|
|
172
152
|
`.refresh_token` instead of `.access_token`. With the pair in place the server
|
|
173
153
|
renews itself and keeps the CURRENT session in `~/.taskswipe-mcp-session.json`
|
|
174
154
|
(owner-only file). That file matters: Supabase rotates refresh tokens on every
|
|
@@ -176,10 +156,9 @@ use and treats reuse of an old one as theft, so the config values are only the
|
|
|
176
156
|
seed — after first run the file is the live credential. Signing out of that
|
|
177
157
|
session (or deleting the file AND pasting a fresh pair) is the reset path.
|
|
178
158
|
|
|
179
|
-
###
|
|
159
|
+
### Password fallback
|
|
180
160
|
|
|
181
|
-
`TASKSWIPE_EMAIL` + `TASKSWIPE_PASSWORD` also work,
|
|
182
|
-
one-time installer does. The server prefers a
|
|
161
|
+
`TASKSWIPE_EMAIL` + `TASKSWIPE_PASSWORD` also work, and the server prefers a
|
|
183
162
|
token whenever both are present. Understand the trade before using it: MCP
|
|
184
163
|
config files sit unencrypted in your home directory, are frequently committed
|
|
185
164
|
by accident, and are read by every tool on the machine. A password there is a
|
|
@@ -193,7 +172,7 @@ The repo root carries `.codex-plugin/plugin.json` + `.mcp.json`, which makes
|
|
|
193
172
|
the whole repo an installable Codex plugin. The path to being listed
|
|
194
173
|
(as of 2026-08):
|
|
195
174
|
|
|
196
|
-
1. ~~Publish to npm~~ — **done
|
|
175
|
+
1. ~~Publish to npm~~ — **done**, `taskswipe-mcp@0.1.0`.
|
|
197
176
|
2. **Interim listing** — submit the GitHub repo URL to the community registry
|
|
198
177
|
at codex-marketplace.com (automated review of the manifest).
|
|
199
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.2.
|
|
4
|
-
"description": "Read and write your TASKKA day from
|
|
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
CHANGED
|
@@ -87,9 +87,10 @@ export function buildBrief(state = {}, now = new Date()) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const missed = arr(S.missedTasks)
|
|
90
|
-
if (missed.length
|
|
91
|
-
attention.push({ area: '
|
|
92
|
-
what: `${missed.length}
|
|
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) })
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// Habits are the app's committed tier: quiet ones are the real signal.
|
|
@@ -121,7 +122,7 @@ export function buildBrief(state = {}, now = new Date()) {
|
|
|
121
122
|
n + (num(e.per100?.kcal) * num(e.grams)) / 100, 0))
|
|
122
123
|
const health = S.healthDaily?.[today] || {}
|
|
123
124
|
|
|
124
|
-
const stacks = arr(S.stacks).map(s => ({
|
|
125
|
+
const stacks = arr(S.stacks).filter(s => !s.retiredAt).map(s => ({
|
|
125
126
|
name: s.name,
|
|
126
127
|
hits: hitRate(stackDoneDays(s), Math.round((num(s.graduationTarget) || 14) * 1.5), now),
|
|
127
128
|
target: num(s.graduationTarget) || 14,
|
|
@@ -135,11 +136,23 @@ export function buildBrief(state = {}, now = new Date()) {
|
|
|
135
136
|
const learningTopics = maps.flatMap(m => arr(m.nodes).filter(n => n.status === 'learning')
|
|
136
137
|
.map(n => `${m.title}: ${n.title}`))
|
|
137
138
|
|
|
139
|
+
const biz = arr(S.businessIdeas)
|
|
138
140
|
const areas = {
|
|
139
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',
|
|
140
145
|
backlog: `${arr(S.backlogTasks).length} parked`,
|
|
146
|
+
docket: `${arr(S.docketTasks).length} coming-up with no clock`,
|
|
141
147
|
deck: `${arr(S.deck).length} cards waiting to be swiped`,
|
|
142
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`,
|
|
143
156
|
missions: `${arr(S.missions).filter(m => !m.done).length} open`,
|
|
144
157
|
goals: goalFrames.length
|
|
145
158
|
? `${goalFrames.length} goals${building ? `, building: ${building.text}` : ', none on the crane'}`
|
|
@@ -192,8 +205,9 @@ export function buildBrief(state = {}, now = new Date()) {
|
|
|
192
205
|
drill: {
|
|
193
206
|
more_on_an_area: "detail(area) — any key from `areas`",
|
|
194
207
|
today_in_full: 'today()',
|
|
208
|
+
dropped_in_full: 'detail({ area: "dropped" })',
|
|
195
209
|
find_anything: 'search(query)',
|
|
196
|
-
write: 'add_tasks, complete_task, capture, set_core_task',
|
|
210
|
+
write: 'add_tasks, complete_task, capture, set_core_task, recover_dropped',
|
|
197
211
|
},
|
|
198
212
|
}
|
|
199
213
|
}
|
|
@@ -223,6 +237,42 @@ export function buildDetail(state = {}, area = '', now = new Date()) {
|
|
|
223
237
|
dropped: arr(S.missedTasks).slice(0, 20).map(t => t.text),
|
|
224
238
|
}
|
|
225
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 }
|
|
226
276
|
case 'habits':
|
|
227
277
|
return arr(S.habits).map(h => {
|
|
228
278
|
const days = habitDoneDays(h)
|
|
@@ -235,6 +285,7 @@ export function buildDetail(state = {}, area = '', now = new Date()) {
|
|
|
235
285
|
const window = Math.round(target * 1.5)
|
|
236
286
|
return {
|
|
237
287
|
name: s.name, cue: s.anchor || null, minimum: s.minimum || null,
|
|
288
|
+
retired: !!s.retiredAt,
|
|
238
289
|
hits: hitRate(days, window, now), target, window,
|
|
239
290
|
ready: hitRate(days, window, now) >= target,
|
|
240
291
|
today: days.has(today),
|
|
@@ -250,6 +301,8 @@ export function buildDetail(state = {}, area = '', now = new Date()) {
|
|
|
250
301
|
}
|
|
251
302
|
case 'food': {
|
|
252
303
|
const day = arr(S.foodLog?.[today])
|
|
304
|
+
const shopping = arr(S.foodShoppingList)
|
|
305
|
+
const openShopping = shopping.filter(row => !row.done)
|
|
253
306
|
const tot = day.reduce((a, e) => {
|
|
254
307
|
const f = num(e.grams) / 100
|
|
255
308
|
return {
|
|
@@ -264,6 +317,27 @@ export function buildDetail(state = {}, area = '', now = new Date()) {
|
|
|
264
317
|
totals: Object.fromEntries(Object.entries(tot).map(([k, v]) => [k, Math.round(v)])),
|
|
265
318
|
targets: S.foodTargets || null,
|
|
266
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,
|
|
267
341
|
saved_meals: arr(S.meals).map(m => m.name),
|
|
268
342
|
}
|
|
269
343
|
}
|
package/src/index.js
CHANGED
|
@@ -23,53 +23,44 @@ import { TOOLS, byName } from './tools.js'
|
|
|
23
23
|
|
|
24
24
|
const log = (...a) => process.stderr.write(a.join(' ') + '\n')
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
26
|
+
const server = new Server(
|
|
27
|
+
{ name: 'taskswipe', version: '0.1.0' },
|
|
28
|
+
{ capabilities: { tools: {} } },
|
|
29
|
+
)
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}))
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
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: `TASKKA: ${e?.message || 'unknown error'}` }],
|
|
57
55
|
}
|
|
58
|
-
}
|
|
56
|
+
}
|
|
57
|
+
})
|
|
59
58
|
|
|
59
|
+
if (process.argv[2] === 'setup') {
|
|
60
|
+
const { runSetup } = await import('./setup.js')
|
|
61
|
+
await runSetup(process.argv.slice(3))
|
|
62
|
+
} else {
|
|
60
63
|
const transport = new StdioServerTransport()
|
|
61
64
|
await server.connect(transport)
|
|
62
65
|
log('[taskswipe] MCP server ready on stdio —', TOOLS.length, 'tools')
|
|
63
66
|
}
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
if (process.argv[2] === 'setup') {
|
|
67
|
-
const { runSetup } = await import('./setup.js')
|
|
68
|
-
await runSetup(process.argv.slice(3))
|
|
69
|
-
} else {
|
|
70
|
-
await startServer()
|
|
71
|
-
}
|
|
72
|
-
} catch (error) {
|
|
73
|
-
log(`TASKKA setup failed: ${error?.message || error}`)
|
|
74
|
-
process.exitCode = 1
|
|
75
|
-
}
|
package/src/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
|
+
}
|
package/src/os.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// CALLUM OS — read the life wiki (kalz-D/callum-os).
|
|
3
|
+
//
|
|
4
|
+
// GitHub is the canonical copy. Hermes also keeps a checkout at
|
|
5
|
+
// /opt/data/callum-os; that is used when no GitHub token is present so the
|
|
6
|
+
// VPS agent can still search the vault. TASKKA's serverless path always uses
|
|
7
|
+
// GitHub.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_REPO = 'kalz-D/callum-os'
|
|
14
|
+
export const DEFAULT_OWNER_EMAIL = 'c.dempsey@live.com.au'
|
|
15
|
+
|
|
16
|
+
const SKIP_DIR = /(?:^|\/)(?:\.obsidian|\.git|node_modules)(?:\/|$)/
|
|
17
|
+
const NOTE_FILE = /\.(md|txt|markdown)$/i
|
|
18
|
+
const INDEX_PATH = 'index.md'
|
|
19
|
+
|
|
20
|
+
function repo() {
|
|
21
|
+
return String(process.env.CALLUM_OS_REPO || DEFAULT_REPO).replace(/^\/+|\/+$/g, '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function token() {
|
|
25
|
+
return process.env.CALLUM_OS_GITHUB_TOKEN || process.env.GITHUB_TOKEN || ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function localDir() {
|
|
29
|
+
const hinted = String(process.env.CALLUM_OS_DIR || '').trim()
|
|
30
|
+
if (hinted) return hinted
|
|
31
|
+
if (fs.existsSync('/opt/data/callum-os/index.md')) return '/opt/data/callum-os'
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function osConfigured() {
|
|
36
|
+
return !!(token() || localDir())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ownerEmails() {
|
|
40
|
+
return String(process.env.CALLUM_OS_OWNER_EMAIL || DEFAULT_OWNER_EMAIL)
|
|
41
|
+
.split(/[,\s]+/)
|
|
42
|
+
.map(s => s.trim().toLowerCase())
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isOsOwner(user) {
|
|
47
|
+
const email = String(user?.email || user?.user_metadata?.email || '').toLowerCase()
|
|
48
|
+
return !!email && ownerEmails().includes(email)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function safePath(value) {
|
|
52
|
+
const raw = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
|
53
|
+
if (!raw || raw.includes('..') || raw.startsWith('/') || SKIP_DIR.test(raw)) return ''
|
|
54
|
+
return raw.slice(0, 400)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isNote(p) {
|
|
58
|
+
return !!p && !SKIP_DIR.test(p) && NOTE_FILE.test(p)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function gh(pathname, { method = 'GET', body, headers = {}, ok } = {}) {
|
|
62
|
+
const auth = token()
|
|
63
|
+
if (!auth) throw Object.assign(new Error('os_unconfigured'), { status: 503 })
|
|
64
|
+
const res = await fetch(`https://api.github.com${pathname}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${auth}`,
|
|
68
|
+
Accept: 'application/vnd.github+json',
|
|
69
|
+
'User-Agent': 'taskka-callum-os',
|
|
70
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
71
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
72
|
+
...headers,
|
|
73
|
+
},
|
|
74
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
75
|
+
})
|
|
76
|
+
const data = await res.json().catch(() => ({}))
|
|
77
|
+
const allowed = ok || (method === 'GET' ? [200] : [200, 201])
|
|
78
|
+
if (!allowed.includes(res.status)) {
|
|
79
|
+
const err = new Error(data.message || `github_${res.status}`)
|
|
80
|
+
err.status = res.status === 403 ? 503 : res.status
|
|
81
|
+
throw err
|
|
82
|
+
}
|
|
83
|
+
return data
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let treeCache = { at: 0, files: [], sha: '' }
|
|
87
|
+
|
|
88
|
+
async function githubTree() {
|
|
89
|
+
if (treeCache.files.length && Date.now() - treeCache.at < 120_000) return treeCache
|
|
90
|
+
const head = await gh(`/repos/${repo()}/commits/main`)
|
|
91
|
+
const sha = head.commit?.tree?.sha || head.sha
|
|
92
|
+
const body = await gh(`/repos/${repo()}/git/trees/${sha}?recursive=1`)
|
|
93
|
+
const files = (body.tree || [])
|
|
94
|
+
.filter(n => n.type === 'blob' && isNote(n.path))
|
|
95
|
+
.map(n => ({ path: n.path, size: n.size || 0, sha: n.sha }))
|
|
96
|
+
treeCache = { at: Date.now(), files, sha }
|
|
97
|
+
return treeCache
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkLocal(dir, prefix = '') {
|
|
101
|
+
const out = []
|
|
102
|
+
let entries = []
|
|
103
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out }
|
|
104
|
+
for (const ent of entries) {
|
|
105
|
+
const rel = prefix ? `${prefix}/${ent.name}` : ent.name
|
|
106
|
+
if (SKIP_DIR.test(rel)) continue
|
|
107
|
+
const full = path.join(dir, ent.name)
|
|
108
|
+
if (ent.isDirectory()) out.push(...walkLocal(full, rel))
|
|
109
|
+
else if (ent.isFile() && isNote(rel)) {
|
|
110
|
+
let size = 0
|
|
111
|
+
try { size = fs.statSync(full).size } catch { /* ignore */ }
|
|
112
|
+
out.push({ path: rel, size, sha: '' })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function listNotes() {
|
|
119
|
+
const root = localDir()
|
|
120
|
+
if (!token() && root) {
|
|
121
|
+
return { repo: 'local', sha: 'local', files: walkLocal(root) }
|
|
122
|
+
}
|
|
123
|
+
const t = await githubTree()
|
|
124
|
+
return { repo: repo(), sha: t.sha, files: t.files }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function folderOf(p) {
|
|
128
|
+
const i = p.indexOf('/')
|
|
129
|
+
return i < 0 ? 'ROOT' : p.slice(0, i)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function foldersFrom(files) {
|
|
133
|
+
const map = new Map()
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
const folder = folderOf(f.path)
|
|
136
|
+
if (!map.has(folder)) map.set(folder, [])
|
|
137
|
+
map.get(folder).push(f)
|
|
138
|
+
}
|
|
139
|
+
return [...map.entries()].map(([name, items]) => ({
|
|
140
|
+
name,
|
|
141
|
+
count: items.length,
|
|
142
|
+
files: items.sort((a, b) => a.path.localeCompare(b.path)),
|
|
143
|
+
}))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function readNote(notePath) {
|
|
147
|
+
const p = safePath(notePath) || INDEX_PATH
|
|
148
|
+
if (!isNote(p) && p !== INDEX_PATH) {
|
|
149
|
+
throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
150
|
+
}
|
|
151
|
+
const root = localDir()
|
|
152
|
+
if (!token() && root) {
|
|
153
|
+
const full = path.join(root, p)
|
|
154
|
+
if (!full.startsWith(path.resolve(root))) {
|
|
155
|
+
throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
156
|
+
}
|
|
157
|
+
const text = fs.readFileSync(full, 'utf8')
|
|
158
|
+
return { path: p, text, sha: '', source: 'local' }
|
|
159
|
+
}
|
|
160
|
+
const encoded = p.split('/').map(encodeURIComponent).join('/')
|
|
161
|
+
const data = await gh(`/repos/${repo()}/contents/${encoded}?ref=main`)
|
|
162
|
+
const text = Buffer.from(data.content || '', 'base64').toString('utf8')
|
|
163
|
+
return { path: data.path || p, text, sha: data.sha || '', source: 'github', htmlUrl: data.html_url || null }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function snippetAround(text, query, span = 140) {
|
|
167
|
+
const lower = text.toLowerCase()
|
|
168
|
+
const q = query.toLowerCase()
|
|
169
|
+
const i = lower.indexOf(q)
|
|
170
|
+
if (i < 0) return text.slice(0, span).trim()
|
|
171
|
+
const start = Math.max(0, i - 40)
|
|
172
|
+
const end = Math.min(text.length, i + q.length + span)
|
|
173
|
+
return `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function searchNotes(query, limit = 20) {
|
|
177
|
+
const q = String(query || '').trim()
|
|
178
|
+
if (q.length < 2) return { query: q, results: [] }
|
|
179
|
+
const cap = Math.max(1, Math.min(40, Number(limit) || 20))
|
|
180
|
+
const { files, repo: repoName } = await listNotes()
|
|
181
|
+
const needle = q.toLowerCase()
|
|
182
|
+
const pathHits = files
|
|
183
|
+
.filter(f => f.path.toLowerCase().includes(needle))
|
|
184
|
+
.map(f => ({ path: f.path, how: 'path', snippet: f.path }))
|
|
185
|
+
|
|
186
|
+
const contentHits = []
|
|
187
|
+
if (token()) {
|
|
188
|
+
try {
|
|
189
|
+
const data = await gh(
|
|
190
|
+
`/search/code?q=${encodeURIComponent(`${q} repo:${repo()} extension:md`)}`,
|
|
191
|
+
{ headers: { Accept: 'application/vnd.github.text-match+json' } },
|
|
192
|
+
)
|
|
193
|
+
for (const item of data.items || []) {
|
|
194
|
+
const p = item.path
|
|
195
|
+
if (!isNote(p)) continue
|
|
196
|
+
const frag = item.text_matches?.[0]?.fragment || p
|
|
197
|
+
contentHits.push({ path: p, how: 'content', snippet: String(frag).replace(/\s+/g, ' ').slice(0, 220) })
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Code search is extra; path hits still work.
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const root = localDir()
|
|
204
|
+
if (root) {
|
|
205
|
+
for (const f of files) {
|
|
206
|
+
try {
|
|
207
|
+
const text = fs.readFileSync(path.join(root, f.path), 'utf8')
|
|
208
|
+
if (text.toLowerCase().includes(needle)) {
|
|
209
|
+
contentHits.push({ path: f.path, how: 'content', snippet: snippetAround(text, q) })
|
|
210
|
+
}
|
|
211
|
+
} catch { /* skip unreadable */ }
|
|
212
|
+
if (contentHits.length >= cap) break
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const seen = new Set()
|
|
218
|
+
const results = []
|
|
219
|
+
for (const hit of [...pathHits, ...contentHits]) {
|
|
220
|
+
const key = hit.path.toLowerCase()
|
|
221
|
+
if (seen.has(key)) continue
|
|
222
|
+
seen.add(key)
|
|
223
|
+
results.push(hit)
|
|
224
|
+
if (results.length >= cap) break
|
|
225
|
+
}
|
|
226
|
+
return { query: q, repo: repoName, count: results.length, results }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function osStatus() {
|
|
230
|
+
const configured = osConfigured()
|
|
231
|
+
if (!configured) {
|
|
232
|
+
return { configured: false, writable: false, repo: repo(), files: 0, source: null }
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const { files, repo: repoName, sha } = await listNotes()
|
|
236
|
+
return {
|
|
237
|
+
configured: true,
|
|
238
|
+
writable: true,
|
|
239
|
+
repo: repoName,
|
|
240
|
+
files: files.length,
|
|
241
|
+
sha,
|
|
242
|
+
source: token() ? 'github' : 'local',
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return { configured: true, writable: !!token() || !!localDir(), repo: repo(), files: 0, error: e.message || 'os_status_failed' }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const MAX_NOTE_BYTES = 400_000
|
|
250
|
+
|
|
251
|
+
export function looksLikeSecret(text) {
|
|
252
|
+
return /ghp_[A-Za-z0-9]|gho_[A-Za-z0-9]|github_pat_|sk-ant-|sk-proj-|sk_live_|BEGIN [A-Z ]*PRIVATE KEY/i.test(String(text || ''))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function ensureFrontmatter(text, { title, type = 'Note' } = {}) {
|
|
256
|
+
const src = String(text || '')
|
|
257
|
+
if (src.startsWith('---')) return src
|
|
258
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
259
|
+
const safeTitle = String(title || 'Note').replace(/[\r\n]+/g, ' ').slice(0, 120)
|
|
260
|
+
return `---\ntype: ${type}\ntitle: ${safeTitle}\ndate: ${date}\nsource: taskka\n---\n\n${src}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function notePathFrom({ folder, name } = {}) {
|
|
264
|
+
const file = String(name || '')
|
|
265
|
+
.replace(/\.md$/i, '')
|
|
266
|
+
.replace(/[^\w\s./-]+/g, '')
|
|
267
|
+
.trim()
|
|
268
|
+
.replace(/\s+/g, ' ')
|
|
269
|
+
if (!file || file.includes('..')) return ''
|
|
270
|
+
const folderRaw = String(folder || '').trim()
|
|
271
|
+
const folderPath = !folderRaw || folderRaw === 'ROOT' ? '' : safePath(folderRaw)
|
|
272
|
+
const rel = folderPath ? `${folderPath}/${file}.md` : `${file}.md`
|
|
273
|
+
return isNote(rel) ? rel : ''
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function bustTree() {
|
|
277
|
+
treeCache = { at: 0, files: [], sha: '' }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function writeNote({ path: notePath, text, message, sha } = {}) {
|
|
281
|
+
const p = safePath(notePath)
|
|
282
|
+
if (!isNote(p)) throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
283
|
+
const body = String(text || '')
|
|
284
|
+
if (!body.trim()) throw Object.assign(new Error('empty_note'), { status: 400 })
|
|
285
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_NOTE_BYTES) {
|
|
286
|
+
throw Object.assign(new Error('note_too_large'), { status: 413 })
|
|
287
|
+
}
|
|
288
|
+
if (looksLikeSecret(body)) {
|
|
289
|
+
throw Object.assign(new Error('secrets_forbidden'), { status: 400 })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const root = localDir()
|
|
293
|
+
if (!token() && root) {
|
|
294
|
+
const full = path.join(root, p)
|
|
295
|
+
if (!full.startsWith(path.resolve(root))) {
|
|
296
|
+
throw Object.assign(new Error('bad_path'), { status: 400 })
|
|
297
|
+
}
|
|
298
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
299
|
+
const created = !fs.existsSync(full)
|
|
300
|
+
fs.writeFileSync(full, body, 'utf8')
|
|
301
|
+
bustTree()
|
|
302
|
+
return { path: p, sha: '', created, source: 'local' }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let currentSha = sha || ''
|
|
306
|
+
if (!currentSha) {
|
|
307
|
+
try {
|
|
308
|
+
const existing = await readNote(p)
|
|
309
|
+
currentSha = existing.sha || ''
|
|
310
|
+
} catch (e) {
|
|
311
|
+
if (e.status !== 404) throw e
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const payload = {
|
|
316
|
+
message: String(message || `TASKKA: ${currentSha ? 'update' : 'create'} ${p}`).slice(0, 200),
|
|
317
|
+
content: Buffer.from(body, 'utf8').toString('base64'),
|
|
318
|
+
branch: 'main',
|
|
319
|
+
}
|
|
320
|
+
if (currentSha) payload.sha = currentSha
|
|
321
|
+
|
|
322
|
+
const encoded = p.split('/').map(encodeURIComponent).join('/')
|
|
323
|
+
const put = () => gh(`/repos/${repo()}/contents/${encoded}`, { method: 'PUT', body: payload })
|
|
324
|
+
let data
|
|
325
|
+
try {
|
|
326
|
+
data = await put()
|
|
327
|
+
} catch (e) {
|
|
328
|
+
if (e.status !== 409 && e.status !== 422) throw e
|
|
329
|
+
const existing = await readNote(p)
|
|
330
|
+
payload.sha = existing.sha
|
|
331
|
+
data = await put()
|
|
332
|
+
}
|
|
333
|
+
bustTree()
|
|
334
|
+
return {
|
|
335
|
+
path: data.content?.path || p,
|
|
336
|
+
sha: data.content?.sha || '',
|
|
337
|
+
created: !currentSha,
|
|
338
|
+
htmlUrl: data.content?.html_url || null,
|
|
339
|
+
source: 'github',
|
|
340
|
+
commit: data.commit?.sha || null,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function appendNote({ path: notePath, text, message } = {}) {
|
|
345
|
+
const addition = String(text || '').trim()
|
|
346
|
+
if (!addition) throw Object.assign(new Error('empty_note'), { status: 400 })
|
|
347
|
+
if (looksLikeSecret(addition)) {
|
|
348
|
+
throw Object.assign(new Error('secrets_forbidden'), { status: 400 })
|
|
349
|
+
}
|
|
350
|
+
let existing
|
|
351
|
+
try {
|
|
352
|
+
existing = await readNote(notePath)
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (e.status === 404) {
|
|
355
|
+
return writeNote({ path: notePath, text: addition.endsWith('\n') ? addition : `${addition}\n`, message })
|
|
356
|
+
}
|
|
357
|
+
throw e
|
|
358
|
+
}
|
|
359
|
+
const base = existing.text || ''
|
|
360
|
+
const next = `${base}${base.endsWith('\n') ? '' : '\n'}${addition}\n`
|
|
361
|
+
return writeNote({
|
|
362
|
+
path: existing.path || notePath,
|
|
363
|
+
text: next,
|
|
364
|
+
sha: existing.sha,
|
|
365
|
+
message: message || `TASKKA: append ${existing.path || notePath}`,
|
|
366
|
+
})
|
|
367
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -10,6 +10,7 @@ import process from 'node:process'
|
|
|
10
10
|
import { persistSession, SESSION_FILE, supa } from './store.js'
|
|
11
11
|
|
|
12
12
|
const SKILL_URL = 'https://taskka.app/.well-known/skills/taskka/SKILL.md'
|
|
13
|
+
const FOOD_ORDERING_SKILL_URL = 'https://taskka.app/.well-known/skills/food-ordering/SKILL.md'
|
|
13
14
|
const PACKAGE = 'taskswipe-mcp@latest'
|
|
14
15
|
|
|
15
16
|
export function parseSetupArgs(argv = []) {
|
|
@@ -40,6 +41,12 @@ export function setupPlan(target) {
|
|
|
40
41
|
args: ['skills', 'install', SKILL_URL],
|
|
41
42
|
optional: true,
|
|
42
43
|
},
|
|
44
|
+
{
|
|
45
|
+
label: 'Installing the guarded food-ordering skill',
|
|
46
|
+
command: 'hermes',
|
|
47
|
+
args: ['skills', 'install', FOOD_ORDERING_SKILL_URL],
|
|
48
|
+
optional: true,
|
|
49
|
+
},
|
|
43
50
|
{
|
|
44
51
|
label: 'Testing the MCP connection',
|
|
45
52
|
command: 'hermes',
|
package/src/store.js
CHANGED
|
@@ -82,32 +82,48 @@ export function supa() {
|
|
|
82
82
|
// design. Delete the file to start over from the env seed.
|
|
83
83
|
export const SESSION_FILE = path.join(os.homedir(), '.taskswipe-mcp-session.json')
|
|
84
84
|
|
|
85
|
+
function sessionCandidates() {
|
|
86
|
+
const out = []
|
|
87
|
+
const seen = new Set()
|
|
88
|
+
const add = file => {
|
|
89
|
+
const p = String(file || '').trim()
|
|
90
|
+
if (!p || seen.has(p)) return
|
|
91
|
+
seen.add(p)
|
|
92
|
+
out.push(p)
|
|
93
|
+
}
|
|
94
|
+
add(process.env.TASKSWIPE_SESSION_FILE)
|
|
95
|
+
add(SESSION_FILE)
|
|
96
|
+
add(path.join(os.homedir(), 'taskswipe-mcp-session.json'))
|
|
97
|
+
add('/opt/data/.taskswipe-mcp-session.json')
|
|
98
|
+
add('/opt/data/taskswipe-mcp-session.json')
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
function loadSavedSession() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
for (const file of sessionCandidates()) {
|
|
104
|
+
try {
|
|
105
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
106
|
+
if (j?.access_token && j?.refresh_token) return j
|
|
107
|
+
} catch { /* try the next path */ }
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
export function persistSession(session) {
|
|
93
|
-
|
|
94
|
-
SESSION_FILE,
|
|
95
|
-
JSON.stringify({
|
|
96
|
-
access_token: session.access_token,
|
|
97
|
-
refresh_token: session.refresh_token,
|
|
98
|
-
expires_at: session.expires_at || null,
|
|
99
|
-
}),
|
|
100
|
-
{ mode: 0o600 },
|
|
101
|
-
)
|
|
102
|
-
// writeFile's mode only applies when a file is first created. Correct an
|
|
103
|
-
// older or manually-created file too, so the credential is always private.
|
|
104
|
-
fs.chmodSync(SESSION_FILE, 0o600)
|
|
105
|
-
return SESSION_FILE
|
|
113
|
+
saveSession(session)
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
function saveSession(session) {
|
|
109
117
|
try {
|
|
110
|
-
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
SESSION_FILE,
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
access_token: session.access_token,
|
|
122
|
+
refresh_token: session.refresh_token,
|
|
123
|
+
expires_at: session.expires_at || null,
|
|
124
|
+
}),
|
|
125
|
+
{ mode: 0o600 }, // it's a credential — owner-only, like an SSH key
|
|
126
|
+
)
|
|
111
127
|
} catch (e) {
|
|
112
128
|
console.error('[taskswipe] could not persist session:', e?.message)
|
|
113
129
|
}
|
|
@@ -193,22 +209,41 @@ export async function userId() {
|
|
|
193
209
|
return cachedUserId
|
|
194
210
|
}
|
|
195
211
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
212
|
+
const email = process.env.TASKSWIPE_EMAIL
|
|
213
|
+
const password = process.env.TASKSWIPE_PASSWORD
|
|
214
|
+
if (email && password) {
|
|
215
|
+
console.error(
|
|
216
|
+
'[taskswipe] Signing in with a stored password. Prefer TASKSWIPE_ACCESS_TOKEN — '
|
|
217
|
+
+ 'it expires on its own. See the README.',
|
|
218
|
+
)
|
|
219
|
+
const { data, error } = await s.auth.signInWithPassword({ email, password })
|
|
220
|
+
if (error || !data?.user) throw new Error(`Sign-in failed: ${error?.message || 'unknown error'}`)
|
|
221
|
+
if (data.session) saveSession(data.session)
|
|
222
|
+
cachedUserId = data.user.id
|
|
223
|
+
return cachedUserId
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw new Error(
|
|
227
|
+
'No TASKKA session for this process. Looked for '
|
|
228
|
+
+ sessionCandidates().join(', ')
|
|
229
|
+
+ ` (HOME=${os.homedir()}). Run \`npx -y taskswipe-mcp@latest setup hermes\` `
|
|
230
|
+
+ 'as the same user the Hermes gateway uses. Setup writes a session file — '
|
|
231
|
+
+ 'do not set TASKSWIPE_EMAIL. Or put TASKSWIPE_ACCESS_TOKEN and '
|
|
232
|
+
+ 'TASKSWIPE_REFRESH_TOKEN in the MCP env.',
|
|
203
233
|
)
|
|
234
|
+
}
|
|
204
235
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
236
|
+
/** Signed-in user for owner-gated tools (Callum OS). */
|
|
237
|
+
export async function currentUser() {
|
|
238
|
+
if (FAKE) return { email: process.env.CALLUM_OS_OWNER_EMAIL || 'c.dempsey@live.com.au' }
|
|
239
|
+
const scoped = requestCtx.getStore()?.supa
|
|
240
|
+
if (scoped) {
|
|
241
|
+
const { data } = await scoped.auth.getUser()
|
|
242
|
+
return data?.user || null
|
|
243
|
+
}
|
|
244
|
+
await userId()
|
|
245
|
+
const { data } = await supa().auth.getUser()
|
|
246
|
+
return data?.user || null
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
/** Current state plus the stamp a later write must match. */
|
package/src/tools.js
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
// can undo a batch.
|
|
14
14
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
|
-
import { readState, mutate } from './store.js'
|
|
16
|
+
import { readState, mutate, currentUser } from './store.js'
|
|
17
17
|
import { buildBrief, buildDetail } from './brief.js'
|
|
18
|
+
import { isOsOwner, osConfigured, osStatus, readNote, searchNotes, writeNote, appendNote, ensureFrontmatter, notePathFrom } from './os.js'
|
|
19
|
+
import { ob1Configured, ob1Status, searchOb1, captureOb1 } from './ob1.js'
|
|
18
20
|
|
|
19
21
|
const MAX_ADD = 10
|
|
20
22
|
const uid = () => Math.random().toString(36).slice(2, 11)
|
|
@@ -92,9 +94,11 @@ export const TOOLS = [
|
|
|
92
94
|
title: 'One area, in depth',
|
|
93
95
|
description:
|
|
94
96
|
'Depth on ONE area named by brief(). Pass any key from the brief\'s '
|
|
95
|
-
+ '`areas` — today,
|
|
96
|
-
+ '
|
|
97
|
-
+ '
|
|
97
|
+
+ '`areas` — today, dropped, backlog, docket, habits, stacks, goals, '
|
|
98
|
+
+ 'food, lift, mind, study, squares, lists, frame, business, people, '
|
|
99
|
+
+ 'things, missions. DROPPED is LOG\'s pile of unfinished tasks from '
|
|
100
|
+
+ 'earlier days. Still summarised: you get what someone would reason '
|
|
101
|
+
+ 'with, not the raw rows.',
|
|
98
102
|
inputSchema: {
|
|
99
103
|
type: 'object',
|
|
100
104
|
properties: { area: { type: 'string', description: 'e.g. "habits", "food", "study"' } },
|
|
@@ -324,6 +328,51 @@ export const TOOLS = [
|
|
|
324
328
|
},
|
|
325
329
|
},
|
|
326
330
|
|
|
331
|
+
{
|
|
332
|
+
name: 'recover_dropped',
|
|
333
|
+
title: 'Put a DROPPED task back on today',
|
|
334
|
+
description:
|
|
335
|
+
'LOG → DROPPED holds tasks that aged out unfinished from earlier days. '
|
|
336
|
+
+ 'This puts one back on today. Refer to it by any part of its text. '
|
|
337
|
+
+ 'Does not charge decay. Use detail({ area: "dropped" }) first if you '
|
|
338
|
+
+ 'do not know the wording.',
|
|
339
|
+
inputSchema: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
task: { type: 'string', minLength: 1, description: 'Any part of the dropped task text.' },
|
|
343
|
+
},
|
|
344
|
+
required: ['task'],
|
|
345
|
+
additionalProperties: false,
|
|
346
|
+
},
|
|
347
|
+
async run({ task }) {
|
|
348
|
+
let result = null
|
|
349
|
+
await mutate(state => {
|
|
350
|
+
const list = arr(state.missedTasks)
|
|
351
|
+
const hit = findTask(list, task)
|
|
352
|
+
if (!hit) { result = { ok: false, reason: 'no_match' }; return null }
|
|
353
|
+
if (hit.ambiguous) { result = { ok: false, reason: 'ambiguous', candidates: hit.ambiguous }; return null }
|
|
354
|
+
const { expiredDate, ...rest } = hit
|
|
355
|
+
const already = arr(state.todayTasks).find(t => !t.done && (t.text || '').toLowerCase() === (rest.text || '').toLowerCase())
|
|
356
|
+
result = { ok: true, task: rest.text, already: !!already }
|
|
357
|
+
return {
|
|
358
|
+
...state,
|
|
359
|
+
missedTasks: list.filter(t => t.id !== hit.id),
|
|
360
|
+
todayTasks: already
|
|
361
|
+
? state.todayTasks
|
|
362
|
+
: [...arr(state.todayTasks), { ...rest, id: uid(), done: false, createdDate: today(), source: 'mcp' }],
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
if (result?.reason === 'no_match') {
|
|
366
|
+
return { ok: false, message: `Nothing in DROPPED matches "${task}". Call detail({ area: "dropped" }).` }
|
|
367
|
+
}
|
|
368
|
+
if (result?.reason === 'ambiguous') {
|
|
369
|
+
return { ok: false, message: `That matches several in DROPPED: ${result.candidates.join(', ')}. Be more specific.` }
|
|
370
|
+
}
|
|
371
|
+
if (result.already) return { ok: true, message: `"${result.task}" was already on today. Removed from DROPPED.` }
|
|
372
|
+
return { ok: true, message: `Put "${result.task}" back on today from DROPPED.` }
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
|
|
327
376
|
{
|
|
328
377
|
name: 'search',
|
|
329
378
|
title: 'Search everything',
|
|
@@ -347,9 +396,14 @@ export const TOOLS = [
|
|
|
347
396
|
if (text && String(text).toLowerCase().includes(q)) hits.push({ where, text: String(text), ...extra })
|
|
348
397
|
}
|
|
349
398
|
arr(state.todayTasks).forEach(t => add('today', t.text, { done: !!t.done }))
|
|
399
|
+
arr(state.missedTasks).forEach(t => add('dropped', t.text, { expired: t.expiredDate || null }))
|
|
400
|
+
arr(state.cutTasks).forEach(t => add('cut', t.text))
|
|
350
401
|
arr(state.backlogTasks).forEach(t => add('backlog', t.text))
|
|
402
|
+
arr(state.docketTasks).forEach(t => add('docket', t.text || t.task))
|
|
351
403
|
arr(state.taskDatabase).forEach(t => add('database', t.text))
|
|
352
404
|
arr(state.ideas).forEach(i => add('ideas', i.text))
|
|
405
|
+
arr(state.businessIdeas).forEach(i => add('business', i.title, { stage: i.stage || 'spark' }))
|
|
406
|
+
arr(state.people).forEach(p => add('people', p.name, { role: p.role || null }))
|
|
353
407
|
arr(state.decompress).forEach(d => add('decompress', d.text))
|
|
354
408
|
Object.entries(state.journal || {}).forEach(([date, entries]) =>
|
|
355
409
|
arr(entries).forEach(e => add('journal', e?.text, { date })))
|
|
@@ -360,6 +414,183 @@ export const TOOLS = [
|
|
|
360
414
|
return { query, count: hits.length, results: hits.slice(0, limit) }
|
|
361
415
|
},
|
|
362
416
|
},
|
|
417
|
+
|
|
418
|
+
{
|
|
419
|
+
name: 'os_search',
|
|
420
|
+
title: 'Search Callum OS',
|
|
421
|
+
description:
|
|
422
|
+
'Search Callum OS, the life wiki: businesses, portfolio, knowledge, '
|
|
423
|
+
+ 'life areas and decisions. Use this when the question is about a '
|
|
424
|
+
+ 'company, a written decision, or anything that lives in the vault '
|
|
425
|
+
+ 'rather than today\'s TASKKA tasks.',
|
|
426
|
+
inputSchema: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
properties: {
|
|
429
|
+
query: { type: 'string', minLength: 2 },
|
|
430
|
+
limit: { type: 'integer', minimum: 1, maximum: 40, default: 12 },
|
|
431
|
+
},
|
|
432
|
+
required: ['query'],
|
|
433
|
+
additionalProperties: false,
|
|
434
|
+
},
|
|
435
|
+
async run({ query, limit = 12 }) {
|
|
436
|
+
const user = await currentUser()
|
|
437
|
+
if (!isOsOwner(user)) throw new Error('Callum OS is locked to the vault owner.')
|
|
438
|
+
if (!osConfigured()) throw new Error('Callum OS is not configured on this server.')
|
|
439
|
+
return searchNotes(query, limit)
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
{
|
|
444
|
+
name: 'os_read',
|
|
445
|
+
title: 'Read a Callum OS note',
|
|
446
|
+
description:
|
|
447
|
+
'Read one markdown note from Callum OS by path, e.g. "index.md", '
|
|
448
|
+
+ '"10 Businesses/Care in Movement/index.md", or "60 Life/index.md". '
|
|
449
|
+
+ 'Call os_search first if you do not know the path.',
|
|
450
|
+
inputSchema: {
|
|
451
|
+
type: 'object',
|
|
452
|
+
properties: { path: { type: 'string', minLength: 1, maxLength: 400 } },
|
|
453
|
+
required: ['path'],
|
|
454
|
+
additionalProperties: false,
|
|
455
|
+
},
|
|
456
|
+
async run({ path }) {
|
|
457
|
+
const user = await currentUser()
|
|
458
|
+
if (!isOsOwner(user)) throw new Error('Callum OS is locked to the vault owner.')
|
|
459
|
+
if (!osConfigured()) throw new Error('Callum OS is not configured on this server.')
|
|
460
|
+
const note = await readNote(path)
|
|
461
|
+
const text = String(note.text || '')
|
|
462
|
+
return { ...note, text: text.length > 12000 ? `${text.slice(0, 12000)}\n…[truncated]` : text }
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
{
|
|
467
|
+
name: 'os_status',
|
|
468
|
+
title: 'Callum OS connection',
|
|
469
|
+
description: 'Whether Callum OS is reachable and how many notes are in the vault.',
|
|
470
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
471
|
+
async run() {
|
|
472
|
+
const user = await currentUser()
|
|
473
|
+
if (!isOsOwner(user)) throw new Error('Callum OS is locked to the vault owner.')
|
|
474
|
+
return osStatus()
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
{
|
|
479
|
+
name: 'os_write',
|
|
480
|
+
title: 'Write a Callum OS note',
|
|
481
|
+
description:
|
|
482
|
+
'Create or replace one markdown note in Callum OS. Pass the full vault '
|
|
483
|
+
+ 'path (e.g. "01 Inbox/capture.md" or "10 Businesses/Care in Movement/notes.md"). '
|
|
484
|
+
+ 'This OVERWRITES the file — call os_read first if you are editing. '
|
|
485
|
+
+ 'New notes get YAML frontmatter with type: Note. Never write secrets.',
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
properties: {
|
|
489
|
+
path: { type: 'string', minLength: 1, maxLength: 400 },
|
|
490
|
+
text: { type: 'string', minLength: 1, maxLength: 50000 },
|
|
491
|
+
message: { type: 'string', maxLength: 200 },
|
|
492
|
+
folder: { type: 'string', description: 'Optional folder when creating from a title instead of a path' },
|
|
493
|
+
title: { type: 'string', description: 'Optional title used to build a new path under folder' },
|
|
494
|
+
},
|
|
495
|
+
additionalProperties: false,
|
|
496
|
+
},
|
|
497
|
+
async run({ path, text, message, folder, title }) {
|
|
498
|
+
const user = await currentUser()
|
|
499
|
+
if (!isOsOwner(user)) throw new Error('Callum OS is locked to the vault owner.')
|
|
500
|
+
if (!osConfigured()) throw new Error('Callum OS is not configured on this server.')
|
|
501
|
+
const target = path || notePathFrom({ folder: folder || '01 Inbox', name: title || 'Note' })
|
|
502
|
+
if (!target) throw new Error('Need a path or a title for the new note.')
|
|
503
|
+
const body = ensureFrontmatter(text, { title: title || target.split('/').pop() })
|
|
504
|
+
const saved = await writeNote({ path: target, text: body, message })
|
|
505
|
+
return { ok: true, ...saved, message: saved.created ? `Created ${saved.path}` : `Updated ${saved.path}` }
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
{
|
|
510
|
+
name: 'os_append',
|
|
511
|
+
title: 'Append to a Callum OS note',
|
|
512
|
+
description:
|
|
513
|
+
'Add text to the end of an existing Callum OS note (creates it if missing). '
|
|
514
|
+
+ 'Use this for log.md, inbox capture, or adding a decision without rewriting the file.',
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: 'object',
|
|
517
|
+
properties: {
|
|
518
|
+
path: { type: 'string', minLength: 1, maxLength: 400 },
|
|
519
|
+
text: { type: 'string', minLength: 1, maxLength: 20000 },
|
|
520
|
+
message: { type: 'string', maxLength: 200 },
|
|
521
|
+
},
|
|
522
|
+
required: ['path', 'text'],
|
|
523
|
+
additionalProperties: false,
|
|
524
|
+
},
|
|
525
|
+
async run({ path, text, message }) {
|
|
526
|
+
const user = await currentUser()
|
|
527
|
+
if (!isOsOwner(user)) throw new Error('Callum OS is locked to the vault owner.')
|
|
528
|
+
if (!osConfigured()) throw new Error('Callum OS is not configured on this server.')
|
|
529
|
+
const saved = await appendNote({ path, text, message })
|
|
530
|
+
return { ok: true, ...saved, message: `Appended to ${saved.path}` }
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
{
|
|
535
|
+
name: 'ob1_status',
|
|
536
|
+
title: 'OB1 connection',
|
|
537
|
+
description: 'Whether One Brain / OB1 is reachable and how many thoughts it holds.',
|
|
538
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
539
|
+
async run() {
|
|
540
|
+
const user = await currentUser()
|
|
541
|
+
if (!isOsOwner(user)) throw new Error('OB1 is locked to the vault owner.')
|
|
542
|
+
return ob1Status()
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
{
|
|
547
|
+
name: 'ob1_search',
|
|
548
|
+
title: 'Search OB1',
|
|
549
|
+
description:
|
|
550
|
+
'Search One Brain / OB1, the durable cross-tool memory. Use this for '
|
|
551
|
+
+ 'preferences, lessons, and decisions that should follow Callum between '
|
|
552
|
+
+ 'apps — not for today\'s TASKKA tasks or a whole Callum OS note.',
|
|
553
|
+
inputSchema: {
|
|
554
|
+
type: 'object',
|
|
555
|
+
properties: {
|
|
556
|
+
query: { type: 'string', minLength: 2 },
|
|
557
|
+
limit: { type: 'integer', minimum: 1, maximum: 20, default: 8 },
|
|
558
|
+
},
|
|
559
|
+
required: ['query'],
|
|
560
|
+
additionalProperties: false,
|
|
561
|
+
},
|
|
562
|
+
async run({ query, limit = 8 }) {
|
|
563
|
+
const user = await currentUser()
|
|
564
|
+
if (!isOsOwner(user)) throw new Error('OB1 is locked to the vault owner.')
|
|
565
|
+
if (!ob1Configured()) throw new Error('OB1 is not configured on this server.')
|
|
566
|
+
return searchOb1(query, { limit })
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
{
|
|
571
|
+
name: 'ob1_capture',
|
|
572
|
+
title: 'Capture to OB1',
|
|
573
|
+
description:
|
|
574
|
+
'Save one concise thought to One Brain / OB1. Use this when a map topic, '
|
|
575
|
+
+ 'Parley card, learn, or note should follow Callum into future AI sessions. '
|
|
576
|
+
+ 'Do not dump a whole vault file. Never write secrets.',
|
|
577
|
+
inputSchema: {
|
|
578
|
+
type: 'object',
|
|
579
|
+
properties: {
|
|
580
|
+
text: { type: 'string', minLength: 1, maxLength: 4000 },
|
|
581
|
+
source: { type: 'string', maxLength: 40, description: 'parley, study-map, note, idea, os' },
|
|
582
|
+
title: { type: 'string', maxLength: 160 },
|
|
583
|
+
},
|
|
584
|
+
required: ['text'],
|
|
585
|
+
additionalProperties: false,
|
|
586
|
+
},
|
|
587
|
+
async run({ text, source, title }) {
|
|
588
|
+
const user = await currentUser()
|
|
589
|
+
if (!isOsOwner(user)) throw new Error('OB1 is locked to the vault owner.')
|
|
590
|
+
if (!ob1Configured()) throw new Error('OB1 is not configured on this server.')
|
|
591
|
+
return captureOb1({ text, source, title })
|
|
592
|
+
},
|
|
593
|
+
},
|
|
363
594
|
]
|
|
364
595
|
|
|
365
596
|
export const byName = Object.fromEntries(TOOLS.map(t => [t.name, t]))
|