@ai-setting/roy-task-plugin 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@
38
38
  * - exit 0 always: a hook that throws must not wedge the session.
39
39
  */
40
40
 
41
- import { openStore, defaultTaskDbPath, BridgeError } from "../mcp/bridge.mjs";
41
+ import { createServer, defaultTaskDbPath, BridgeError } from "../mcp/bridge.mjs";
42
42
 
43
43
  /** Read stdin fully, with a hard cap so a huge transcript cannot stall us. */
44
44
  async function readStdin(limitBytes = 2 * 1024 * 1024) {
@@ -132,15 +132,23 @@ function parseTranscript(raw) {
132
132
  }
133
133
 
134
134
  /** The most recently updated task that is still open. */
135
- function findOpenTask(db) {
136
- const row = db
137
- .prepare(
138
- `SELECT id, title, status FROM task
139
- WHERE status IN ('todo', 'active')
140
- ORDER BY updated_at DESC LIMIT 1`,
141
- )
142
- .get();
143
- return row ?? null;
135
+ /**
136
+ * Find the most recently touched task that is still open.
137
+ *
138
+ * Task #4619 — this used to be a hand-written SQL query against roy's own
139
+ * database (`SELECT id, title, status FROM task WHERE status IN ('todo',
140
+ * 'active') ORDER BY updated_at DESC LIMIT 1`). The *query* is task
141
+ * semantics, so it belongs to core, not to the plugin.
142
+ *
143
+ * It is now expressed against core's `TaskBackend`, which owns the status
144
+ * vocabulary and the ordering.
145
+ */
146
+ async function findOpenTask(backend) {
147
+ const result = await backend.listTasks({ limit: 200 });
148
+ const open = result.tasks
149
+ .filter((t) => t.status === "todo" || t.status === "active")
150
+ .sort((a, b) => (b.updatedAt ?? b.updated_at ?? 0) - (a.updatedAt ?? a.updated_at ?? 0));
151
+ return open[0] ?? null;
144
152
  }
145
153
 
146
154
  function approve() {
@@ -185,9 +193,11 @@ async function main() {
185
193
  return;
186
194
  }
187
195
 
188
- let db;
196
+ let backend;
189
197
  try {
190
- db = openStore(process.env.ROY_TASK_DB_PATH || defaultTaskDbPath());
198
+ const server = createServer(process.env.ROY_TASK_DB_PATH || defaultTaskDbPath());
199
+ // The server owns the store; the backend is what the hook needs.
200
+ backend = server.backend;
191
201
  } catch (err) {
192
202
  // A store we cannot open is not a reason to block the user.
193
203
  approve();
@@ -195,7 +205,7 @@ async function main() {
195
205
  }
196
206
 
197
207
  try {
198
- const open = findOpenTask(db);
208
+ const open = await findOpenTask(backend);
199
209
  if (!open) {
200
210
  approve();
201
211
  return;
@@ -210,12 +220,6 @@ async function main() {
210
220
  return;
211
221
  }
212
222
  approve();
213
- } finally {
214
- try {
215
- db.close();
216
- } catch {
217
- // already closed
218
- }
219
223
  }
220
224
  }
221
225
 
@@ -1,39 +1,48 @@
1
1
  /**
2
- * Bridge: MCP tool call -> direct SQLite access on roy-agent's task store.
2
+ * Bridge: roy's task store + roy's MCP server, wired together.
3
3
  *
4
- * ## Why direct SQLite and not the `roy-agent tasks` CLI
4
+ * ## What this file used to be
5
5
  *
6
- * The CLI was the first idea and it works for most tools — but it **cannot
7
- * create operation records**: `roy-agent tasks operations <id>` only *reads*
8
- * them (measured: the subcommand's flags are `--type / --limit / --offset /
9
- * --json`, there is no `--create`). Since `task_operation_create` is a
10
- * first-class part of the task lifecycle (milestones are the state
11
- * dimension of a task), the MCP server cannot go through the CLI alone.
6
+ * Before Task #4619 this was a **second implementation** of roy's task
7
+ * semantics: 482 lines that created its own `task` / `task_operation`
8
+ * schema and implemented every tool as raw SQL — `taskTree` (BFS +
9
+ * `pruneDepth`), `taskCreate`, `taskGet`, `taskList` (COUNT for `total`),
10
+ * `taskUpdate`, `taskOperationCreate`, `taskComplete`, `taskSearch` — with
11
+ * **zero** imports from `@ai-setting/roy-agent-core`.
12
12
  *
13
- * So the server talks to the store directly, and this is safe because:
13
+ * That was the same defect review #4371 found on the opencode side (an
14
+ * 869-line `facade.ts`), except it hid inside a `.mjs` file, so the reviews
15
+ * that counted TypeScript lines never saw it.
14
16
  *
15
- * 1. **Same database.** `~/.local/share/roy-agent/tasks.db` is the file the
16
- * roy agent writes and the progress card renders. Writing anywhere else
17
- * would make Claude's tasks invisible to the card.
18
- * 2. **`node:sqlite`, not a native addon.** Node 22+ ships SQLite in core.
19
- * No `better-sqlite3` compile step, no `bun:sqlite` — the same file works
20
- * under plain node, which is the whole point of the published package.
21
- * (Measured: `better-sqlite3@11.10.0`'s native module aborts the process
22
- * under node 24 with `Assertion failed: (env) != nullptr`.)
23
- * 3. **Schema is stable and read-only by default.** We open the database in
24
- * read-write mode, but every statement is explicit and parameterised.
17
+ * ## What it is now
25
18
  *
26
- * ## Migration safety
19
+ * A thin adapter with exactly three responsibilities:
27
20
  *
28
- * We do **not** run migrations. The `task` / `task_operation` tables are
29
- * created and migrated by roy-agent itself. If a column this server needs is
30
- * missing, we surface that as a tool error rather than guessing a default.
21
+ * 1. **where** the store lives (roy-agent's own `tasks.db`, not the
22
+ * plugin's private one — otherwise Claude would create tasks the
23
+ * progress card can never see);
24
+ * 2. **binding** that store to roy's semantics (`TaskStoreBackend` +
25
+ * `createSQLiteTaskPort`, both from core);
26
+ * 3. **transport** — handing core's MCP server the bytes.
27
+ *
28
+ * Every semantic decision (tree shape, depth, `total`, deliverable
29
+ * merging, milestone filtering, search filters) lives in core, once.
30
+ *
31
+ * ## Runtime
32
+ *
33
+ * Plain node (`#!/usr/bin/env node` in the server entry) — hence core's
34
+ * `node:sqlite` support (no native `better-sqlite3` needed) and core's
35
+ * `createCompatibleDatabase` compat shim.
31
36
  */
32
37
 
33
- import { DatabaseSync } from "node:sqlite";
34
- import { mkdirSync } from "node:fs";
35
- import { dirname, join } from "node:path";
38
+ import { createTaskMcpServer } from "@ai-setting/roy-agent-core/env/task/mcp";
39
+ import {
40
+ TaskStoreBackend,
41
+ createSQLiteTaskPort,
42
+ } from "@ai-setting/roy-agent-core/env/task/backend";
43
+ import { SQLiteTaskStore } from "@ai-setting/roy-agent-core/env/task/storage";
36
44
  import { homedir } from "node:os";
45
+ import { join } from "node:path";
37
46
 
38
47
  /** Raised when a tool call cannot be served. */
39
48
  export class BridgeError extends Error {
@@ -50,433 +59,45 @@ export function defaultTaskDbPath() {
50
59
  return join(base, "roy-agent", "tasks.db");
51
60
  }
52
61
 
53
- /** The columns `task` exposes, in the order the CLI's JSON output uses. */
54
- function rowToTask(row) {
55
- return {
56
- id: row.id,
57
- title: row.title,
58
- description: row.description ?? "",
59
- status: row.status,
60
- priority: row.priority,
61
- progress: row.progress ?? 0,
62
- current_status: row.current_status ?? "",
63
- goals_and_expected_deliverables: row.goals_and_expected_deliverables ?? "",
64
- parent_task_id: row.parent_task_id ?? null,
65
- createdAt: toIso(row.created_at),
66
- updatedAt: toIso(row.updated_at),
67
- due_date: row.due_date ?? null,
68
- tags: parseTags(row.tags),
69
- project_path: row.project_path ?? "",
70
- context: row.context ?? "",
71
- type: row.type ?? "normal",
72
- };
73
- }
74
-
75
- function rowToOperation(row) {
76
- return {
77
- id: row.id,
78
- task_id: row.task_id,
79
- session_id: row.session_id ?? null,
80
- milestone_type: row.milestone_type,
81
- milestone_title: row.milestone_title,
82
- milestone_description: row.milestone_description ?? "",
83
- timestamp: toIso(row.timestamp),
84
- md_path: row.md_path ?? null,
85
- process_description: row.process_description ?? "",
86
- };
87
- }
88
-
89
- function toIso(value) {
90
- if (value === null || value === undefined) return new Date().toISOString();
91
- if (typeof value === "number") return new Date(value).toISOString();
92
- const asNumber = Number(value);
93
- if (Number.isFinite(asNumber) && String(value).trim() !== "") {
94
- return new Date(asNumber).toISOString();
95
- }
96
- return String(value);
97
- }
98
-
99
- function parseTags(value) {
100
- if (!value) return [];
101
- try {
102
- const parsed = JSON.parse(value);
103
- return Array.isArray(parsed) ? parsed.map(String) : [];
104
- } catch {
105
- return String(value)
106
- .split(",")
107
- .map((t) => t.trim())
108
- .filter(Boolean);
109
- }
110
- }
111
-
112
- function requireString(args, key) {
113
- const value = args[key];
114
- if (typeof value !== "string" || value.length === 0) {
115
- throw new BridgeError(`missing required string argument "${key}"`);
116
- }
117
- return value;
118
- }
119
-
120
- function optionalString(args, key) {
121
- const value = args[key];
122
- if (value === undefined || value === null) return undefined;
123
- if (typeof value !== "string") {
124
- throw new BridgeError(`argument "${key}" must be a string`);
125
- }
126
- return value;
127
- }
128
-
129
- function optionalNumber(args, key) {
130
- const value = args[key];
131
- if (value === undefined || value === null) return undefined;
132
- const num = typeof value === "number" ? value : Number(value);
133
- if (!Number.isFinite(num)) {
134
- throw new BridgeError(`argument "${key}" must be a number`);
135
- }
136
- return num;
137
- }
138
-
139
- function optionalStringArray(args, key) {
140
- const value = args[key];
141
- if (value === undefined || value === null) return undefined;
142
- if (!Array.isArray(value)) {
143
- throw new BridgeError(`argument "${key}" must be an array of strings`);
144
- }
145
- return value.map((v) => String(v));
146
- }
147
-
148
62
  /**
149
- * The subset of roy-agent's schema this server needs.
150
- *
151
- * We do not run roy-agent's migrations — that would duplicate a versioned
152
- * migration chain we do not own. Instead we create the tables only when they
153
- * are **entirely absent**, using the same column set the current roy-agent
154
- * schema has. If the tables exist we touch nothing, so an existing store is
155
- * never rewritten by this server.
63
+ * Build the MCP server over roy's own task store.
156
64
  *
157
- * The `id INTEGER PRIMARY KEY AUTOINCREMENT` form matters: `task.id` is the
158
- * handle every other API (the CLI, the progress card, the roy agent's own
159
- * tools) uses, and it must keep incrementing across server restarts.
65
+ * This is the whole "binding" the plugin does: core's `SQLiteTaskStore` →
66
+ * core's `createSQLiteTaskPort` → core's `TaskStoreBackend` (the semantics)
67
+ * → core's `createTaskMcpServer` (the transport-agnostic JSON-RPC surface).
160
68
  */
161
- const FALLBACK_SCHEMA = `
162
- CREATE TABLE IF NOT EXISTS task (
163
- id INTEGER PRIMARY KEY AUTOINCREMENT,
164
- title TEXT NOT NULL,
165
- description TEXT,
166
- status TEXT NOT NULL DEFAULT 'todo',
167
- priority TEXT NOT NULL DEFAULT 'medium',
168
- progress INTEGER NOT NULL DEFAULT 0,
169
- current_status TEXT,
170
- goals_and_expected_deliverables TEXT,
171
- parent_task_id INTEGER,
172
- created_at INTEGER,
173
- updated_at INTEGER,
174
- due_date TEXT,
175
- tags TEXT,
176
- project_path TEXT,
177
- context TEXT,
178
- type TEXT NOT NULL DEFAULT 'normal'
179
- );
180
- CREATE TABLE IF NOT EXISTS task_operation (
181
- id INTEGER PRIMARY KEY AUTOINCREMENT,
182
- task_id INTEGER,
183
- session_id TEXT,
184
- milestone_type TEXT,
185
- milestone_title TEXT,
186
- milestone_description TEXT,
187
- timestamp INTEGER,
188
- md_path TEXT,
189
- process_description TEXT
190
- );
191
- CREATE INDEX IF NOT EXISTS task_operation_task_idx ON task_operation(task_id);
192
- `;
193
-
194
- /** Open (and create if needed) the task store. */
195
- export function openStore(dbPath = defaultTaskDbPath()) {
196
- if (dbPath !== ":memory:") {
197
- try {
198
- mkdirSync(dirname(dbPath), { recursive: true });
199
- } catch {
200
- // best effort — the open below will produce the real error
201
- }
202
- }
203
- const db = new DatabaseSync(dbPath);
204
- try {
205
- db.exec(FALLBACK_SCHEMA);
206
- } catch (err) {
207
- // An existing store whose schema differs is roy-agent's business, not
208
- // ours. Surface the problem instead of silently corrupting it.
209
- process.stderr.write(
210
- `roy-task mcp server: schema check failed on ${dbPath}: ${err && err.message}\n`,
211
- );
212
- }
213
- return db;
69
+ export function createServer(dbPath = defaultTaskDbPath()) {
70
+ const store = new SQLiteTaskStore(dbPath);
71
+ const backend = new TaskStoreBackend(createSQLiteTaskPort(store));
72
+ const server = createTaskMcpServer(backend, {
73
+ name: "roy-task",
74
+ version: "1.0.0",
75
+ sessionId: process.env.ROY_TASK_SESSION_ID || "claude-mcp",
76
+ });
77
+ // The hooks (stop-check) need the *backend*, not the MCP surface: they
78
+ // ask "is there an open task?" — a task-semantics question, answered by
79
+ // core's backend, not by the JSON-RPC layer.
80
+ return { ...server, backend, store };
214
81
  }
215
82
 
216
83
  /**
217
84
  * Serve one tool call.
218
85
  *
219
- * Returns a JSON-serialisable value. Throws `BridgeError` for bad input and
220
- * plain `Error` for store problems; the caller turns both into MCP errors.
86
+ * Kept as a **synchronous-looking** helper for the MCP entry point: the
87
+ * returned promise resolves to a JSON-serialisable value. Throws
88
+ * `BridgeError` for an unknown tool; other failures surface as tool errors
89
+ * inside the response (never as an unhandled rejection).
221
90
  */
222
- export function callTool(db, name, args = {}) {
223
- switch (name) {
224
- case "task_tree":
225
- return taskTree(db, args);
226
- case "task_create":
227
- return taskCreate(db, args);
228
- case "task_get":
229
- return taskGet(db, args);
230
- case "task_list":
231
- return taskList(db, args);
232
- case "task_update":
233
- return taskUpdate(db, args);
234
- case "task_operation_create":
235
- return taskOperationCreate(db, args);
236
- case "task_complete":
237
- return taskComplete(db, args);
238
- case "task_search":
239
- return taskSearch(db, args);
240
- default:
241
- throw new BridgeError(`unknown tool "${name}"`);
242
- }
243
- }
244
-
245
- function taskTree(db, args) {
246
- const depth = optionalNumber(args, "depth");
247
- const rootId = optionalNumber(args, "parent_task_id");
248
- const rows = db.prepare("SELECT * FROM task ORDER BY id ASC").all();
249
- const tasks = rows.map(rowToTask);
250
- const byId = new Map(tasks.map((t) => [t.id, { ...t, children: [] }]));
251
- const roots = [];
252
- for (const node of byId.values()) {
253
- const parent = node.parent_task_id ? byId.get(node.parent_task_id) : undefined;
254
- if (parent) parent.children.push(node);
255
- else roots.push(node);
256
- }
257
- const scope = rootId !== undefined ? byId.get(rootId) : undefined;
258
- const selected = scope ? [scope] : roots;
259
- const limited =
260
- depth === undefined || depth < 0 ? selected : pruneDepth(selected, depth);
261
- return { total: tasks.length, roots: limited.length, tasks: limited };
262
- }
263
-
264
- function pruneDepth(nodes, depth) {
265
- if (depth <= 0) return nodes.map((n) => ({ ...n, children: [] }));
266
- return nodes.map((n) => ({ ...n, children: pruneDepth(n.children, depth - 1) }));
267
- }
268
-
269
- function taskCreate(db, args) {
270
- const title = requireString(args, "title");
271
- const now = Date.now();
272
- const description = optionalString(args, "description") ?? "";
273
- const goals = optionalString(args, "goals_and_expected_deliverables") ?? "";
274
- const priority = optionalString(args, "priority") ?? "medium";
275
- const type = optionalString(args, "type") ?? "normal";
276
- const parentId = optionalNumber(args, "parent_task_id") ?? null;
277
- const projectPath = optionalString(args, "project_path") ?? "";
278
- const context = optionalString(args, "context") ?? "";
279
- const tags = optionalStringArray(args, "tags") ?? [];
280
-
281
- const info = db
282
- .prepare(
283
- `INSERT INTO task
284
- (title, description, status, priority, progress, current_status,
285
- goals_and_expected_deliverables, parent_task_id, created_at, updated_at,
286
- tags, project_path, context, type)
287
- VALUES (?, ?, 'todo', ?, 0, '', ?, ?, ?, ?, ?, ?, ?, ?)`,
288
- )
289
- .run(
290
- title,
291
- description,
292
- priority,
293
- goals,
294
- parentId,
295
- now,
296
- now,
297
- JSON.stringify(tags),
298
- projectPath,
299
- context,
300
- type,
301
- );
302
- const id = Number(info.lastInsertRowid);
303
- const created = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
304
- return rowToTask(created);
305
- }
306
-
307
- function taskGet(db, args) {
308
- const id = optionalNumber(args, "id");
309
- if (id === undefined) throw new BridgeError('missing required number argument "id"');
310
- const row = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
311
- if (!row) throw new BridgeError(`task ${id} not found`);
312
- const task = rowToTask(row);
313
- const ops = db
314
- .prepare("SELECT * FROM task_operation WHERE task_id = ? ORDER BY id ASC")
315
- .all(id)
316
- .map(rowToOperation);
317
- return { ...task, operations: ops };
318
- }
319
-
320
- function taskList(db, args) {
321
- const clauses = [];
322
- const params = [];
323
- const status = optionalString(args, "status");
324
- if (status) {
325
- clauses.push("status = ?");
326
- params.push(status);
327
- }
328
- const priority = optionalString(args, "priority");
329
- if (priority) {
330
- clauses.push("priority = ?");
331
- params.push(priority);
332
- }
333
- const type = optionalString(args, "type");
334
- if (type) {
335
- clauses.push("type = ?");
336
- params.push(type);
91
+ export async function callTool(server, name, args = {}) {
92
+ if (typeof name !== "string" || name.length === 0) {
93
+ throw new BridgeError("tools/call requires a tool name");
337
94
  }
338
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
339
- const total = db.prepare(`SELECT COUNT(*) AS c FROM task${where}`).get(...params).c;
340
- const limit = optionalNumber(args, "limit") ?? 20;
341
- const offset = optionalNumber(args, "offset") ?? 0;
342
- const rows = db
343
- .prepare(`SELECT * FROM task${where} ORDER BY id DESC LIMIT ? OFFSET ?`)
344
- .all(...params, limit, offset);
345
- return {
346
- total,
347
- count: rows.length,
348
- truncated: total > rows.length,
349
- limit,
350
- offset,
351
- tasks: rows.map(rowToTask),
352
- };
353
- }
354
-
355
- function taskUpdate(db, args) {
356
- const id = optionalNumber(args, "id");
357
- if (id === undefined) throw new BridgeError('missing required number argument "id"');
358
- const existing = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
359
- if (!existing) throw new BridgeError(`task ${id} not found`);
360
-
361
- const sets = [];
362
- const params = [];
363
- const push = (col, value) => {
364
- sets.push(`${col} = ?`);
365
- params.push(value);
366
- };
367
-
368
- const status = optionalString(args, "status");
369
- if (status !== undefined) push("status", status);
370
- const progress = optionalNumber(args, "progress");
371
- if (progress !== undefined) push("progress", progress);
372
- const currentStatus = optionalString(args, "current_status");
373
- if (currentStatus !== undefined) push("current_status", currentStatus);
374
- const description = optionalString(args, "description");
375
- if (description !== undefined) push("description", description);
376
- const goals = optionalString(args, "goals_and_expected_deliverables");
377
- if (goals !== undefined) push("goals_and_expected_deliverables", goals);
378
- const priority = optionalString(args, "priority");
379
- if (priority !== undefined) push("priority", priority);
380
- const type = optionalString(args, "type");
381
- if (type !== undefined) push("type", type);
382
- push("updated_at", Date.now());
383
-
384
- params.push(id);
385
- db.prepare(`UPDATE task SET ${sets.join(", ")} WHERE id = ?`).run(...params);
386
- return rowToTask(db.prepare("SELECT * FROM task WHERE id = ?").get(id));
387
- }
388
-
389
- function taskOperationCreate(db, args) {
390
- const taskId = optionalNumber(args, "task_id");
391
- if (taskId === undefined) {
392
- throw new BridgeError('missing required number argument "task_id"');
393
- }
394
- const exists = db.prepare("SELECT id FROM task WHERE id = ?").get(taskId);
395
- if (!exists) throw new BridgeError(`task ${taskId} not found`);
396
-
397
- const milestoneType = requireString(args, "milestone_type");
398
- const milestoneTitle = requireString(args, "milestone_title");
399
- const milestoneDescription = optionalString(args, "milestone_description") ?? "";
400
- const processDescription = optionalString(args, "process_description") ?? null;
401
- const sessionId = optionalString(args, "session_id") ?? null;
402
-
403
- // The 100-150 char rule is roy-agent's, not ours — but the CLI validates it
404
- // on the way in, so the MCP tool has to as well or the two paths diverge.
405
- if (processDescription !== null) {
406
- const len = processDescription.length;
407
- if (len > 0 && (len < 100 || len > 150)) {
408
- throw new BridgeError(
409
- `process_description must be 100-150 characters (got ${len})`,
410
- );
411
- }
412
- }
413
-
414
- const info = db
415
- .prepare(
416
- `INSERT INTO task_operation
417
- (task_id, session_id, milestone_type, milestone_title,
418
- milestone_description, timestamp, md_path, process_description)
419
- VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`,
420
- )
421
- .run(
422
- taskId,
423
- sessionId,
424
- milestoneType,
425
- milestoneTitle,
426
- milestoneDescription,
427
- Date.now(),
428
- processDescription,
95
+ try {
96
+ return await server.callTool(name, args);
97
+ } catch (err) {
98
+ if (err instanceof BridgeError) throw err;
99
+ throw new BridgeError(
100
+ err instanceof Error ? err.message : `tool "${name}" failed: ${String(err)}`,
429
101
  );
430
- const row = db
431
- .prepare("SELECT * FROM task_operation WHERE id = ?")
432
- .get(Number(info.lastInsertRowid));
433
- return rowToOperation(row);
434
- }
435
-
436
- function taskComplete(db, args) {
437
- const id = optionalNumber(args, "id");
438
- if (id === undefined) throw new BridgeError('missing required number argument "id"');
439
- const existing = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
440
- if (!existing) throw new BridgeError(`task ${id} not found`);
441
-
442
- const deliverable = optionalString(args, "deliverable_storage_path");
443
- const now = Date.now();
444
- const sets = ["status = ?", "progress = 100", "updated_at = ?"];
445
- const params = ["completed", now];
446
- if (deliverable) {
447
- // Same convention roy-agent uses: the deliverable lives in the task's
448
- // context JSON under `deliverable_storage_path`.
449
- let context = {};
450
- try {
451
- context = existing.context ? JSON.parse(existing.context) : {};
452
- } catch {
453
- context = {};
454
- }
455
- context.deliverable_storage_path = deliverable;
456
- sets.push("context = ?");
457
- params.push(JSON.stringify(context));
458
- }
459
- params.push(id);
460
- db.prepare(`UPDATE task SET ${sets.join(", ")} WHERE id = ?`).run(...params);
461
- return rowToTask(db.prepare("SELECT * FROM task WHERE id = ?").get(id));
462
- }
463
-
464
- function taskSearch(db, args) {
465
- const keywords = optionalStringArray(args, "keywords");
466
- if (!keywords || keywords.length === 0) {
467
- throw new BridgeError('missing required array argument "keywords"');
468
- }
469
- const limit = optionalNumber(args, "limit") ?? 20;
470
- const where = keywords
471
- .map(() => "(title LIKE ? OR description LIKE ? OR goals_and_expected_deliverables LIKE ? OR tags LIKE ?)")
472
- .join(" AND ");
473
- const params = [];
474
- for (const kw of keywords) {
475
- const like = `%${kw}%`;
476
- params.push(like, like, like, like);
477
102
  }
478
- const rows = db
479
- .prepare(`SELECT * FROM task WHERE ${where} ORDER BY id DESC LIMIT ?`)
480
- .all(...params, limit);
481
- return { total: rows.length, count: rows.length, tasks: rows.map(rowToTask) };
482
103
  }