taskswipe-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js CHANGED
@@ -13,7 +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
+ 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'
17
20
 
18
21
  const MAX_ADD = 10
19
22
  const uid = () => Math.random().toString(36).slice(2, 11)
@@ -23,6 +26,27 @@ const today = () => {
23
26
  }
24
27
 
25
28
  const arr = (v) => (Array.isArray(v) ? v : [])
29
+ const num = (v) => (Number.isFinite(+v) ? +v : 0)
30
+
31
+ // Mirrors GOAL_FRAMES and creditGoal in src/lib/goals.js. Duplicated rather
32
+ // than imported because this package ships standalone to npm and cannot reach
33
+ // into the app's src/. If the goal model changes there, change it here too.
34
+ const GOAL_FRAMES = ['3month', '6month', 'yearly', '3year', '7year']
35
+ function creditGoal(goals = {}, { goalId, goalFrame, squares }) {
36
+ if (!goalId || !squares) return goals || {}
37
+ const frames = goalFrame && GOAL_FRAMES.includes(goalFrame) ? [goalFrame] : GOAL_FRAMES
38
+ const next = { ...(goals || {}) }
39
+ for (const frame of frames) {
40
+ const list = arr(next[frame])
41
+ const idx = list.findIndex(g => g.id === goalId)
42
+ if (idx < 0) continue
43
+ next[frame] = list.map((g, i) => (i === idx
44
+ ? { ...g, earnedBricks: Math.max(0, num(g.earnedBricks) + squares) }
45
+ : g))
46
+ return next
47
+ }
48
+ return goals || {}
49
+ }
26
50
 
27
51
  /** Match a task the way a person refers to it: by substring, not by id. */
28
52
  function findTask(list, ref) {
@@ -43,11 +67,55 @@ function findTask(list, ref) {
43
67
  }
44
68
 
45
69
  export const TOOLS = [
70
+ // FIRST CALL, ALWAYS. Six tools covering three of twenty-five tabs left an
71
+ // agent guessing what existed and stitching several reads into a picture. One
72
+ // bounded call now describes the whole system — see brief.js for why it
73
+ // summarises rather than serialising.
74
+ {
75
+ name: 'brief',
76
+ title: 'The whole picture, briefly',
77
+ description:
78
+ 'START HERE for anything about how the user is doing, what they should do '
79
+ + 'next, or what is going on in any part of their life. Returns a compact '
80
+ + 'snapshot of the ENTIRE app — today, what needs attention ranked by '
81
+ + 'severity, and a one-line state for every area (habits, stacks, goals, '
82
+ + 'food, lift, walk, mind, study, squares and the rest). About a kilobyte '
83
+ + 'whether they joined yesterday or two years ago. Follow with detail() '
84
+ + 'for depth on one area.',
85
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
86
+ async run() {
87
+ const { state } = await readState()
88
+ return buildBrief(state)
89
+ },
90
+ },
91
+
92
+ {
93
+ name: 'detail',
94
+ title: 'One area, in depth',
95
+ description:
96
+ 'Depth on ONE area named by brief(). Pass any key from the brief\'s '
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.',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: { area: { type: 'string', description: 'e.g. "habits", "food", "study"' } },
105
+ required: ['area'],
106
+ additionalProperties: false,
107
+ },
108
+ async run({ area }) {
109
+ const { state } = await readState()
110
+ return buildDetail(state, area)
111
+ },
112
+ },
113
+
46
114
  {
47
115
  name: 'today',
48
116
  title: "Today's plan",
49
117
  description:
50
- "What TASK//SWIPE has for today: open and completed tasks, the core task, "
118
+ "What TASKKA has for today: open and completed tasks, the core task, "
51
119
  + "the day score if posted, and today's timed blocks. Call this before "
52
120
  + "answering anything about what the user is doing today.",
53
121
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
@@ -130,10 +198,19 @@ export const TOOLS = [
130
198
  + 'a few words is enough, no id needed. Returns the exact task matched.',
131
199
  inputSchema: {
132
200
  type: 'object',
133
- properties: { task: { type: 'string', minLength: 1, description: 'Any part of the task text.' } },
201
+ properties: {
202
+ task: { type: 'string', minLength: 1, description: 'Any part of the task text.' },
203
+ squares: {
204
+ type: 'integer', minimum: 0, maximum: 48,
205
+ description:
206
+ 'How many squares the task was worth. Omit unless the user says — '
207
+ + 'a recurring rule with a preset worth supplies its own, and '
208
+ + 'anything else is worth 0 until the user values it in the app.',
209
+ },
210
+ },
134
211
  required: ['task'], additionalProperties: false,
135
212
  },
136
- async run({ task }) {
213
+ async run({ task, squares }) {
137
214
  let result = null
138
215
  await mutate(state => {
139
216
  const list = arr(state.todayTasks)
@@ -141,10 +218,52 @@ export const TOOLS = [
141
218
  if (!hit) { result = { ok: false, reason: 'no_match' }; return null }
142
219
  if (hit.ambiguous) { result = { ok: false, reason: 'ambiguous', candidates: hit.ambiguous }; return null }
143
220
  if (hit.done) { result = { ok: true, already: true, task: hit.text }; return null }
144
- result = { ok: true, task: hit.text }
221
+
222
+ // Mirrors the COMPLETE_TASK reducer in src/store.jsx. Ticking a task is
223
+ // not just a flag: it earns squares, writes a history entry, feeds the
224
+ // level counter, and credits any linked stack or goal. Setting `done`
225
+ // alone — which is all this did — silently dropped every one of those,
226
+ // so a recurring task configured as worth 8 earned nothing when ticked
227
+ // from here. Keep the two in step.
228
+ //
229
+ // Coerce like the reducer does: one non-finite value would make
230
+ // totalSquares NaN, and NaN survives every later addition, quietly
231
+ // destroying the currency for good.
232
+ const asked = squares !== undefined ? squares : hit.presetSquares
233
+ const earned = Number.isFinite(+asked) ? Math.max(0, +asked) : 0
234
+
235
+ const newToday = list.map(t => (t.id === hit.id
236
+ ? {
237
+ ...t, done: true, completedAt: Date.now(),
238
+ // Settling the parent settles its steps, as the app does.
239
+ ...(t.subtasks ? { subtasks: t.subtasks.map(sub => ({ ...sub, done: true })) } : {}),
240
+ }
241
+ : t))
242
+
243
+ const entry = {
244
+ ...hit, done: true, squares: earned,
245
+ stackId: hit.stackId, notes: '', at: Date.now(), source: 'mcp',
246
+ }
247
+ if (!entry.goalId) { delete entry.goalId; delete entry.goalFrame }
248
+
249
+ const newTotal = num(state.totalSquares) + earned
250
+ const leveledUp = earned > 0
251
+ && Math.floor(num(state.totalSquares) / 48) !== Math.floor(newTotal / 48)
252
+
253
+ const newStacks = hit.stackId
254
+ ? arr(state.stacks).map(st => (st.id === hit.stackId
255
+ ? { ...st, completions: [...arr(st.completions), { date: today(), task: hit.text }] }
256
+ : st))
257
+ : state.stacks
258
+
259
+ result = { ok: true, task: hit.text, squares: earned, total: newTotal, leveledUp }
145
260
  return {
146
261
  ...state,
147
- todayTasks: list.map(t => (t.id === hit.id ? { ...t, done: true, completedAt: Date.now() } : t)),
262
+ todayTasks: newToday,
263
+ achievedTasks: [entry, ...arr(state.achievedTasks)],
264
+ totalSquares: newTotal,
265
+ stacks: newStacks,
266
+ goals: earned > 0 ? creditGoal(state.goals, { goalId: entry.goalId, goalFrame: entry.goalFrame, squares: earned }) : state.goals,
148
267
  }
149
268
  })
150
269
  if (!result?.ok && result?.reason === 'no_match') {
@@ -153,7 +272,15 @@ export const TOOLS = [
153
272
  if (result?.reason === 'ambiguous') {
154
273
  return { ok: false, message: `That matches several: ${result.candidates.join(', ')}. Be more specific.` }
155
274
  }
156
- return { ok: true, message: result.already ? `"${result.task}" was already done.` : `Ticked off "${result.task}".` }
275
+ if (result.already) return { ok: true, message: `"${result.task}" was already done.` }
276
+ const earned = result.squares
277
+ ? ` +${result.squares} square${result.squares === 1 ? '' : 's'} (${result.total} total)`
278
+ : ''
279
+ return {
280
+ ok: true,
281
+ message: `Ticked off "${result.task}"${earned}.`
282
+ + (result.leveledUp ? ' That completed a brick — level up.' : ''),
283
+ }
157
284
  },
158
285
  },
159
286
 
@@ -201,6 +328,51 @@ export const TOOLS = [
201
328
  },
202
329
  },
203
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
+
204
376
  {
205
377
  name: 'search',
206
378
  title: 'Search everything',
@@ -224,9 +396,14 @@ export const TOOLS = [
224
396
  if (text && String(text).toLowerCase().includes(q)) hits.push({ where, text: String(text), ...extra })
225
397
  }
226
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))
227
401
  arr(state.backlogTasks).forEach(t => add('backlog', t.text))
402
+ arr(state.docketTasks).forEach(t => add('docket', t.text || t.task))
228
403
  arr(state.taskDatabase).forEach(t => add('database', t.text))
229
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 }))
230
407
  arr(state.decompress).forEach(d => add('decompress', d.text))
231
408
  Object.entries(state.journal || {}).forEach(([date, entries]) =>
232
409
  arr(entries).forEach(e => add('journal', e?.text, { date })))
@@ -237,6 +414,183 @@ export const TOOLS = [
237
414
  return { query, count: hits.length, results: hits.slice(0, limit) }
238
415
  },
239
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
+ },
240
594
  ]
241
595
 
242
596
  export const byName = Object.fromEntries(TOOLS.map(t => [t.name, t]))