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 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
- ## One-time VPS setup (Hermes / OpenClaw)
28
+ ## Setup
29
29
 
30
- Run one command in an interactive SSH session as the same Linux user that runs
31
- the agent:
32
-
33
- ```bash
34
- npx -y taskswipe-mcp@latest setup hermes
35
- ```
36
-
37
- or:
38
-
39
- ```bash
40
- npx -y taskswipe-mcp@latest setup openclaw
41
- ```
42
-
43
- The installer asks for the TASKKA email and password. Password input is hidden;
44
- the password is used once to mint a separate session for that VPS and is never
45
- written to disk. The renewable session is saved at
46
- `~/.taskswipe-mcp-session.json` with mode `0600`, then the installer registers
47
- the stdio MCP, installs the TASKKA skill and runs the agent's connection check.
48
- No tunnel, public port, YAML/JSON edit or hourly token copy is involved.
49
-
50
- This is safer than copying the browser refresh token: the browser and VPS have
51
- independent sessions and can rotate their credentials without racing each other.
52
-
53
- ## Manual setup and other clients
54
-
55
- The production Supabase URL and anon key are baked in as defaults (they're
56
- public — they ship in the web app's JS bundle); self-hosters can override them
57
- with `TASKSWIPE_SUPABASE_URL` and `TASKSWIPE_SUPABASE_ANON_KEY`.
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) — prefer the one-command setup above. Its resulting
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
- The server reads the renewable VPS session from the owner-only session file.
92
+ Copy both snippets from TASKKA → SETTINGS → AGENTS (signed in) so the tokens are live.
115
93
 
116
- **OpenClaw** — prefer the one-command setup above. Its resulting entry is:
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@latest"]
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
- ### Legacy token-seeded sessions
149
+ ### Making it permanent
170
150
 
171
- For older configs, add `TASKSWIPE_REFRESH_TOKEN` next to it — same console snippet, ending in
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
- ### Stored-password fallback (not recommended)
159
+ ### Password fallback
180
160
 
181
- `TASKSWIPE_EMAIL` + `TASKSWIPE_PASSWORD` also work, but this is not what the
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**. The one-time VPS installer ships in `taskswipe-mcp@0.2.0`.
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.0",
4
- "description": "Read and write your TASKKA day from Hermes, OpenClaw, Claude Code, Codex or Cursor.",
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 >= 3) {
91
- attention.push({ area: 'today', severity: 1,
92
- what: `${missed.length} tasks aged out unfinished and are sitting in DROPPED` })
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
- async function startServer() {
27
- const server = new Server(
28
- { name: 'taskswipe', version: '0.2.0' },
29
- { capabilities: { tools: {} } },
30
- )
26
+ const server = new Server(
27
+ { name: 'taskswipe', version: '0.1.0' },
28
+ { capabilities: { tools: {} } },
29
+ )
31
30
 
32
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
- tools: TOOLS.map(t => ({
34
- name: t.name,
35
- title: t.title,
36
- description: t.description,
37
- inputSchema: t.inputSchema,
38
- })),
39
- }))
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
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
42
- const tool = byName[req.params.name]
43
- if (!tool) {
44
- return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }] }
45
- }
46
- try {
47
- const result = await tool.run(req.params.arguments || {})
48
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
49
- } catch (e) {
50
- // Returned as a tool error rather than thrown: the model can read it and
51
- // tell the user what to fix, instead of the client showing "server error".
52
- log(`[taskswipe] ${tool.name} failed:`, e?.message || e)
53
- return {
54
- isError: true,
55
- content: [{ type: 'text', text: `TASKKA: ${e?.message || 'unknown error'}` }],
56
- }
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
- 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 }
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
- fs.writeFileSync(
94
- SESSION_FILE,
95
- JSON.stringify({
96
- access_token: session.access_token,
97
- refresh_token: session.refresh_token,
98
- expires_at: session.expires_at || null,
99
- }),
100
- { mode: 0o600 },
101
- )
102
- // writeFile's mode only applies when a file is first created. Correct an
103
- // older or manually-created file too, so the credential is always private.
104
- fs.chmodSync(SESSION_FILE, 0o600)
105
- return SESSION_FILE
113
+ saveSession(session)
106
114
  }
107
115
 
108
116
  function saveSession(session) {
109
117
  try {
110
- persistSession(session)
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
- // A password in an MCP config is a non-expiring credential for the whole
197
- // account, sitting in plaintext in a file that gets committed by accident all
198
- // the time. It still works — but it should never be the silent default, so
199
- // say so. stderr, never stdout: stdout is the protocol.
200
- console.error(
201
- '[taskswipe] Signing in with a stored password. Prefer TASKSWIPE_ACCESS_TOKEN — '
202
- + 'it expires on its own. See the README.',
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
- const email = env('TASKSWIPE_EMAIL')
206
- const password = env('TASKSWIPE_PASSWORD')
207
- const { data, error } = await s.auth.signInWithPassword({ email, password })
208
- if (error || !data?.user) throw new Error(`Sign-in failed: ${error?.message || 'unknown error'}`)
209
- if (data.session) saveSession(data.session)
210
- cachedUserId = data.user.id
211
- return cachedUserId
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, habits, stacks, goals, food, lift, mind, study, '
96
- + 'squares, backlog, missions. Still summarised: you get what someone '
97
- + 'would reason with, not the raw rows.',
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]))