assistme 0.3.0 → 0.3.2

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.
Files changed (44) hide show
  1. package/PLAN.md +14 -3
  2. package/dist/{chunk-UWE5WVQI.js → chunk-KX7ITO55.js} +20 -11
  3. package/dist/index.js +1791 -572
  4. package/dist/{job-runner-N4XAAWLJ.js → job-runner-P2L6MOOX.js} +1 -1
  5. package/package.json +5 -3
  6. package/src/agent/job-runner.ts +9 -13
  7. package/src/agent/mcp-servers.ts +6 -1020
  8. package/src/agent/memory.ts +2 -11
  9. package/src/agent/processor.ts +18 -108
  10. package/src/agent/scheduler.ts +2 -3
  11. package/src/agent/session.ts +20 -36
  12. package/src/agent/skills.ts +167 -61
  13. package/src/agent/system-prompt.ts +126 -0
  14. package/src/browser/chrome-launcher.ts +555 -0
  15. package/src/browser/controller.ts +1386 -0
  16. package/src/browser/types.ts +70 -0
  17. package/src/commands/credential.ts +190 -0
  18. package/src/commands/job.ts +14 -45
  19. package/src/commands/memory.ts +16 -29
  20. package/src/commands/schedule.ts +15 -37
  21. package/src/commands/start.ts +11 -43
  22. package/src/credentials/credential-store.test.ts +162 -0
  23. package/src/credentials/credential-store.ts +266 -0
  24. package/src/credentials/encryption.test.ts +98 -0
  25. package/src/credentials/encryption.ts +82 -0
  26. package/src/credentials/index.ts +15 -0
  27. package/src/credentials/local-store.ts +89 -0
  28. package/src/db/action.ts +19 -0
  29. package/src/db/api-client.ts +3 -32
  30. package/src/db/auth-store.ts +41 -0
  31. package/src/db/auth.ts +38 -0
  32. package/src/db/conversation.ts +39 -0
  33. package/src/db/event.ts +52 -0
  34. package/src/db/job-poll.ts +18 -0
  35. package/src/db/session.ts +60 -0
  36. package/src/db/supabase.ts +40 -383
  37. package/src/db/task.ts +69 -0
  38. package/src/db/types.ts +54 -0
  39. package/src/index.ts +2 -0
  40. package/src/mcp/agent-tools-server.ts +1047 -0
  41. package/src/mcp/browser-server.ts +258 -0
  42. package/src/tools/browser.ts +28 -1208
  43. package/src/tools/index.ts +32 -263
  44. package/src/tools/web.ts +0 -73
@@ -0,0 +1,1047 @@
1
+ import {
2
+ createSdkMcpServer,
3
+ tool,
4
+ type McpSdkServerConfigWithInstance,
5
+ } from "@anthropic-ai/claude-agent-sdk";
6
+ import { z } from "zod/v4";
7
+ import { log } from "../utils/logger.js";
8
+ import type { MemoryManager, MemoryCategory } from "../agent/memory.js";
9
+ import type { SkillManager } from "../agent/skills.js";
10
+ import {
11
+ substituteArguments,
12
+ preprocessDynamicContext,
13
+ validateSkillName,
14
+ } from "../agent/skills.js";
15
+ import { emitEvent, setActionRequest, pollActionResponse } from "../db/supabase.js";
16
+ import { callMcpHandler } from "../db/api-client.js";
17
+ import { JobRunner } from "../agent/job-runner.js";
18
+ import { createScheduledTask, getNextRunTime } from "../agent/scheduler.js";
19
+ import { getCredentialStore, type CredentialType } from "../credentials/index.js";
20
+
21
+ // ── Agent Tools MCP Server (memory, skills) ─────────────────────────
22
+
23
+ export interface AgentToolsDeps {
24
+ memoryManager: MemoryManager | null;
25
+ skillManager: SkillManager;
26
+ taskId: string;
27
+ sessionId?: string;
28
+ }
29
+
30
+ export function createAgentToolsServer(deps: AgentToolsDeps): McpSdkServerConfigWithInstance {
31
+ const { memoryManager, skillManager, taskId, sessionId } = deps;
32
+
33
+ return createSdkMcpServer({
34
+ name: "assistme-agent",
35
+ version: "1.0.0",
36
+ tools: [
37
+ tool(
38
+ "memory_store",
39
+ "Store a memory about the user that persists across conversations. Use when you learn preferences, habits, or standing instructions.",
40
+ {
41
+ content: z.string().describe("What to remember (concise, factual statement)"),
42
+ category: z
43
+ .string()
44
+ .optional()
45
+ .describe("Category: general, preference, instruction, context, skill_learned, fact"),
46
+ importance: z
47
+ .number()
48
+ .optional()
49
+ .describe("Importance 1-10 (default: 5). Use 8+ for instructions"),
50
+ tags: z.array(z.string()).optional().describe("Optional tags for searchability"),
51
+ },
52
+ async (args) => {
53
+ if (!memoryManager) {
54
+ return {
55
+ content: [{ type: "text", text: "Memory manager not available." }],
56
+ };
57
+ }
58
+ const mem = await memoryManager.remember(
59
+ args.content,
60
+ (args.category as MemoryCategory) || "general",
61
+ {
62
+ importance: args.importance || 5,
63
+ tags: args.tags || [],
64
+ sourceMessageId: taskId,
65
+ }
66
+ );
67
+ const result = `Memory stored: "${mem.content}" [${mem.category}, importance: ${mem.importance}]`;
68
+ return { content: [{ type: "text", text: result }] };
69
+ }
70
+ ),
71
+ tool(
72
+ "skill_create",
73
+ "Create a new skill and add it to the user's collection. Returns the skill ID on success.",
74
+ {
75
+ name: z.string().describe("Skill name in kebab-case, e.g. 'flight-booking'"),
76
+ description: z.string().describe("One-line description of what this skill does"),
77
+ instructions: z.string().describe("Markdown step-by-step instructions"),
78
+ emoji: z.string().optional().describe("Single emoji representing this skill"),
79
+ },
80
+ async (args) => {
81
+ // Validate skill name format
82
+ const nameError = validateSkillName(args.name);
83
+ if (nameError) {
84
+ return {
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: `Invalid skill name: ${nameError}. Use lowercase kebab-case like "flight-booking".`,
89
+ },
90
+ ],
91
+ };
92
+ }
93
+
94
+ // Check for duplicates in user's collection
95
+ const existing = skillManager.findSimilar(args.name);
96
+ if (existing) {
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: `A similar skill "${existing.name}" already exists in your collection. Use skill_improve to update it instead.`,
102
+ },
103
+ ],
104
+ };
105
+ }
106
+
107
+ // Create in skills table (repository)
108
+ const result = await skillManager.create(args.name, args.description, args.instructions, {
109
+ source: "manual",
110
+ emoji: args.emoji,
111
+ });
112
+
113
+ if (!result) {
114
+ return {
115
+ content: [{ type: "text", text: `Failed to create skill "${args.name}".` }],
116
+ };
117
+ }
118
+
119
+ // Auto-add to user's collection via upsert_agent_skill RPC
120
+ await skillManager.syncToAgentSkills(
121
+ args.name,
122
+ args.description,
123
+ args.instructions,
124
+ "1.0.0",
125
+ {
126
+ source: "manual",
127
+ emoji: args.emoji,
128
+ sourceSkillId: result.id,
129
+ }
130
+ );
131
+
132
+ log.success(`Skill "${args.name}" created and added to collection`);
133
+ return {
134
+ content: [
135
+ {
136
+ type: "text",
137
+ text: `Skill "${args.name}" created and added to your collection (ID: ${result.id}).`,
138
+ },
139
+ ],
140
+ };
141
+ }
142
+ ),
143
+ tool(
144
+ "skill_improve",
145
+ "Improve an existing skill with better instructions based on what you just learned. Version auto-bumped.",
146
+ {
147
+ name: z.string().describe("Name of the existing skill to improve"),
148
+ improved_instructions: z
149
+ .string()
150
+ .describe("Full updated markdown instructions (not a diff)"),
151
+ description: z.string().optional().describe("Updated description (optional)"),
152
+ },
153
+ async (args) => {
154
+ const existing = skillManager.get(args.name);
155
+ if (!existing) {
156
+ const available = skillManager
157
+ .getAll()
158
+ .map((s) => s.name)
159
+ .join(", ");
160
+ return {
161
+ content: [
162
+ {
163
+ type: "text",
164
+ text: `Skill "${args.name}" not found. Available skills: ${available}`,
165
+ },
166
+ ],
167
+ };
168
+ }
169
+
170
+ const updated = skillManager.update(
171
+ args.name,
172
+ args.improved_instructions,
173
+ args.description
174
+ );
175
+ if (updated) {
176
+ log.success(`Self-improvement: improved skill "${args.name}"`);
177
+ return {
178
+ content: [
179
+ {
180
+ type: "text",
181
+ text: `Skill "${args.name}" improved and version bumped.`,
182
+ },
183
+ ],
184
+ };
185
+ }
186
+
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: `Failed to update skill "${args.name}".`,
192
+ },
193
+ ],
194
+ };
195
+ }
196
+ ),
197
+ tool(
198
+ "skill_invoke",
199
+ "Load a skill's full instructions when relevant to the current task. " +
200
+ "Call this when you determine a skill from the Available Skills list matches the user's request.",
201
+ {
202
+ name: z.string().describe("Skill name from the Available Skills list"),
203
+ arguments: z
204
+ .string()
205
+ .optional()
206
+ .describe("Arguments to pass to the skill (replaces $ARGUMENTS placeholders)"),
207
+ },
208
+ async (args) => {
209
+ const skill = skillManager.get(args.name);
210
+ if (!skill) {
211
+ const available = skillManager
212
+ .getAll()
213
+ .map((s) => s.name)
214
+ .join(", ");
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: `Skill "${args.name}" not found. Available skills: ${available}`,
220
+ },
221
+ ],
222
+ };
223
+ }
224
+
225
+ let content = skill.content;
226
+
227
+ // Substitute $ARGUMENTS placeholders
228
+ if (args.arguments) {
229
+ content = substituteArguments(content, args.arguments);
230
+ }
231
+
232
+ // Preprocess dynamic context (!`command` syntax)
233
+ content = preprocessDynamicContext(content);
234
+
235
+ // Build response with skill content
236
+ let response = `## Skill: ${skill.name}\n`;
237
+ if (skill.description) {
238
+ response += `*${skill.description}*\n`;
239
+ }
240
+ response += `\n${content}`;
241
+
242
+ // Note allowed tools restriction if specified
243
+ if (skill.allowedTools.length > 0) {
244
+ response += `\n\n**Allowed tools for this skill:** ${skill.allowedTools.join(", ")}\n`;
245
+ }
246
+
247
+ // Check required credentials and inject available ones
248
+ const credReqs = skill.metadata.credentials;
249
+ if (credReqs && credReqs.length > 0) {
250
+ const store = getCredentialStore();
251
+ const missing: string[] = [];
252
+
253
+ for (const req of credReqs) {
254
+ const cred = store.getByName(req.name);
255
+ if (cred) {
256
+ response += `\n\n**Credential: ${req.name}** (${req.type})\n`;
257
+ response += `\`\`\`json\n${JSON.stringify(cred.data, null, 2)}\n\`\`\`\n`;
258
+ } else if (req.required) {
259
+ missing.push(`${req.name} (${req.description})`);
260
+ }
261
+ }
262
+
263
+ if (missing.length > 0) {
264
+ response += `\n\n**Missing required credentials:**\n`;
265
+ for (const m of missing) {
266
+ response += `- ${m}\n`;
267
+ }
268
+ response += `\nUse \`ask_user\` to request these from the user, or create them yourself (e.g. register an account), then store with \`credential_set\`.\n`;
269
+ }
270
+ }
271
+
272
+ log.info(`Skill invoked: "${args.name}"`);
273
+
274
+ // Log invocation to DB (fire-and-forget)
275
+ skillManager
276
+ .logInvocation(args.name, {
277
+ messageId: taskId,
278
+ sessionId,
279
+ arguments: args.arguments,
280
+ })
281
+ .catch(() => {});
282
+
283
+ return {
284
+ content: [{ type: "text", text: response }],
285
+ };
286
+ }
287
+ ),
288
+ tool(
289
+ "skill_search",
290
+ "Search for skills by keyword. Uses full-text search across skill names, descriptions, and content. " +
291
+ "Use this to discover relevant skills when the Available Skills list doesn't have an obvious match.",
292
+ {
293
+ query: z.string().describe("Search query (keywords, topic, or task description)"),
294
+ limit: z.number().optional().describe("Max results (default: 5)"),
295
+ },
296
+ async (args) => {
297
+ const results = await skillManager.searchDb(args.query, args.limit || 5);
298
+
299
+ if (results.length === 0) {
300
+ return {
301
+ content: [{ type: "text", text: `No skills found for "${args.query}".` }],
302
+ };
303
+ }
304
+
305
+ let response = `## Skills matching "${args.query}"\n\n`;
306
+ for (const r of results) {
307
+ const emoji = r.emoji ? `${r.emoji} ` : "";
308
+ const usage = r.invocationCount > 0 ? ` (used ${r.invocationCount}x)` : "";
309
+ response += `- **${emoji}${r.name}**${usage}: ${r.description}\n`;
310
+ }
311
+ response += "\nUse skill_invoke to load any of these skills.";
312
+
313
+ return { content: [{ type: "text", text: response }] };
314
+ }
315
+ ),
316
+ tool(
317
+ "skill_generate",
318
+ "Prepare context for generating skills from a job description. Returns existing skills and job info " +
319
+ "so you can analyze the job and create skills using skill_create (which auto-adds to user's collection). " +
320
+ "After creating all skills, call skill_link_job to link them to the job and mark it as analyzed.",
321
+ {
322
+ job_name: z
323
+ .string()
324
+ .describe(
325
+ "Short name for this job/role. Example: '电商运营', 'Frontend Dev', 'Data Analyst'"
326
+ ),
327
+ job_description: z
328
+ .string()
329
+ .describe(
330
+ "Description of the user's job, role, and daily tasks. Can be in any language. " +
331
+ "Example: '我是电商运营,每天要看竞品价格、写商品文案、回复客户评论'"
332
+ ),
333
+ },
334
+ async (args) => {
335
+ const existingNames = skillManager.getAll().map((s) => s.name);
336
+
337
+ let response = `## Job: ${args.job_name}\n`;
338
+ response += `**Description:** ${args.job_description}\n\n`;
339
+
340
+ if (existingNames.length > 0) {
341
+ response += `**Existing skills (do NOT duplicate):** ${existingNames.join(", ")}\n\n`;
342
+ }
343
+
344
+ response += `**Your task:** Analyze this job description and decompose it into 4-10 automatable skills.\n\n`;
345
+ response += `**IMPORTANT — You MUST use ask_user before creating skills:**\n`;
346
+ response += `1. Analyze the job and draft a list of proposed skills (name, emoji, one-line description for each).\n`;
347
+ response += `2. Call \`ask_user\` with the formatted skill list as "question" and these options:\n`;
348
+ response += ` - options: [{label: "Approve All", action_key: "approve_all", description: "Create all proposed skills"}, {label: "Cancel", action_key: "cancel", description: "Do not create any skills"}]\n`;
349
+ response += `3. WAIT for the response. If action_key is "approve_all", create all skills using \`skill_create\`. If "cancel", stop.\n`;
350
+ response += `4. Do NOT ask for confirmation in text. Do NOT create skills without calling ask_user first.\n\n`;
351
+ response += `For each skill, call \`skill_create\` with:\n`;
352
+ response += `- name: kebab-case name (e.g. "slack-message-check")\n`;
353
+ response += `- description: one-line description\n`;
354
+ response += `- instructions: detailed step-by-step markdown instructions the agent can follow\n`;
355
+ response += `- emoji: a single emoji representing the skill\n\n`;
356
+ response += `skill_create automatically adds the skill to the user's collection — no need to call skill_add.\n\n`;
357
+ response += `After ALL skills are created, call \`skill_link_job\` with job_name="${args.job_name}" and the list of created skill names to link them and mark the job as analyzed.\n\n`;
358
+ response += `**Guidelines for skill instructions:**\n`;
359
+ response += `- Write clear, actionable markdown steps\n`;
360
+ response += `- Reference browser tools (browser_navigate, browser_click, browser_read_page, etc.) for web tasks\n`;
361
+ response += `- Include error handling steps\n`;
362
+ response += `- Use placeholders like {query}, {date} for variable inputs\n`;
363
+ response += `- Each skill should be a single, well-defined workflow (10-25 steps)\n`;
364
+
365
+ return { content: [{ type: "text", text: response }] };
366
+ }
367
+ ),
368
+ tool(
369
+ "skill_link_job",
370
+ "Link created skills to a job and mark it as analyzed. Call this after creating all skills for a job via skill_create + skill_add.",
371
+ {
372
+ job_name: z.string().describe("Name of the job to link skills to"),
373
+ job_description: z.string().describe("Job description (used if job doesn't exist yet)"),
374
+ skill_names: z.array(z.string()).describe("Names of skills to link to this job"),
375
+ },
376
+ async (args) => {
377
+ try {
378
+ await saveJobToDb(args.job_name, args.job_description, args.skill_names);
379
+ log.success(
380
+ `Job "${args.job_name}": linked ${args.skill_names.length} skills and marked as analyzed`
381
+ );
382
+ return {
383
+ content: [
384
+ {
385
+ type: "text",
386
+ text: `Job "${args.job_name}" linked with ${args.skill_names.length} skills and marked as analyzed.`,
387
+ },
388
+ ],
389
+ };
390
+ } catch (err) {
391
+ return {
392
+ content: [
393
+ {
394
+ type: "text",
395
+ text: `Failed to link job: ${err instanceof Error ? err.message : err}`,
396
+ },
397
+ ],
398
+ };
399
+ }
400
+ }
401
+ ),
402
+ tool(
403
+ "skill_browse",
404
+ "Browse the skill marketplace to discover skills published by the community. " +
405
+ "Search by keyword, filter by category, and sort by popularity or rating.",
406
+ {
407
+ query: z.string().optional().describe("Search keywords"),
408
+ category: z
409
+ .string()
410
+ .optional()
411
+ .describe("Filter by category (e.g. 'productivity', 'ecommerce', 'dev-tools')"),
412
+ sort: z
413
+ .enum(["popular", "recent", "rating"])
414
+ .optional()
415
+ .describe("Sort order (default: popular)"),
416
+ limit: z.number().optional().describe("Max results (default: 10)"),
417
+ },
418
+ async (args) => {
419
+ const results = await skillManager.browse({
420
+ query: args.query,
421
+ category: args.category,
422
+ sort: args.sort,
423
+ limit: args.limit || 10,
424
+ });
425
+
426
+ if (results.length === 0) {
427
+ const hint = args.query ? ` for "${args.query}"` : "";
428
+ return {
429
+ content: [{ type: "text", text: `No skills found in marketplace${hint}.` }],
430
+ };
431
+ }
432
+
433
+ let response = "## Skill Marketplace\n\n";
434
+ for (const s of results) {
435
+ const emoji = s.emoji ? `${s.emoji} ` : "";
436
+ const rating = s.avgRating ? ` ★${s.avgRating}` : "";
437
+ const installs = s.installCount > 0 ? ` (${s.installCount} installs)` : "";
438
+ const author = s.authorName ? ` by ${s.authorName}` : "";
439
+ response += `- **${emoji}${s.name}** v${s.version}${author}${installs}${rating}\n`;
440
+ response += ` ${s.description}\n`;
441
+ response += ` ID: \`${s.id}\`\n\n`;
442
+ }
443
+ response += "Use `skill_add` with the skill ID to add any of these to your collection.";
444
+
445
+ return { content: [{ type: "text", text: response }] };
446
+ }
447
+ ),
448
+ tool(
449
+ "skill_add",
450
+ "Add a skill to your personal collection. Works for both marketplace skills and newly created drafts. " +
451
+ "This is the approval step — after adding, the skill becomes available for use via skill_invoke.",
452
+ {
453
+ skill_id: z
454
+ .string()
455
+ .describe("The skill UUID (from skill_browse or skill_create results)"),
456
+ },
457
+ async (args) => {
458
+ const added = await skillManager.addSkill(args.skill_id);
459
+ if (!added) {
460
+ return {
461
+ content: [
462
+ { type: "text", text: `Failed to add skill. Check that the ID is correct.` },
463
+ ],
464
+ };
465
+ }
466
+
467
+ const emoji = added.metadata.emoji ? `${added.metadata.emoji} ` : "";
468
+ return {
469
+ content: [
470
+ {
471
+ type: "text",
472
+ text: `Added **${emoji}${added.name}** v${added.version} to your collection. It's now available for use via skill_invoke.`,
473
+ },
474
+ ],
475
+ };
476
+ }
477
+ ),
478
+ tool(
479
+ "skill_publish",
480
+ "Publish one of your skills to the marketplace so others can discover and install it.",
481
+ {
482
+ name: z.string().describe("Name of your skill to publish"),
483
+ category: z
484
+ .string()
485
+ .optional()
486
+ .describe("Category (e.g. 'productivity', 'ecommerce', 'dev-tools')"),
487
+ author_name: z.string().optional().describe("Your display name as the author"),
488
+ },
489
+ async (args) => {
490
+ const skill = skillManager.get(args.name);
491
+ if (!skill) {
492
+ return {
493
+ content: [
494
+ { type: "text", text: `Skill "${args.name}" not found in your collection.` },
495
+ ],
496
+ };
497
+ }
498
+
499
+ if (skill.source === "external") {
500
+ return {
501
+ content: [{ type: "text", text: `Cannot publish external skills.` }],
502
+ };
503
+ }
504
+
505
+ const result = await skillManager.publish(args.name, {
506
+ category: args.category,
507
+ authorName: args.author_name,
508
+ });
509
+
510
+ if (!result) {
511
+ return {
512
+ content: [
513
+ {
514
+ type: "text",
515
+ text: `Failed to publish "${args.name}". The name may already be taken by another author.`,
516
+ },
517
+ ],
518
+ };
519
+ }
520
+
521
+ return {
522
+ content: [
523
+ {
524
+ type: "text",
525
+ text: `Skill "${args.name}" published to the marketplace! Others can now find and install it.`,
526
+ },
527
+ ],
528
+ };
529
+ }
530
+ ),
531
+
532
+ // ── User Interaction Tool ───────────────────────────────────
533
+
534
+ tool(
535
+ "ask_user",
536
+ "Ask the user a question via the web UI and wait for their response. " +
537
+ "Shows a message with optional predefined option buttons PLUS a free-text input field — " +
538
+ "the user can either click a suggested option or type a custom answer. " +
539
+ "ALWAYS provide options when you can suggest likely answers. " +
540
+ "Do NOT use this for information you can discover yourself (git remote, file contents, etc.).",
541
+ {
542
+ question: z
543
+ .string()
544
+ .describe(
545
+ "The question to ask (supports markdown). Be specific about what you need and why."
546
+ ),
547
+ options: z
548
+ .array(
549
+ z.object({
550
+ label: z.string().describe("Button label shown to user"),
551
+ action_key: z.string().describe("Machine-readable key returned when selected"),
552
+ description: z.string().optional().describe("Tooltip/description for this option"),
553
+ })
554
+ )
555
+ .optional()
556
+ .describe(
557
+ "Suggested options shown as buttons. The user can always type a custom answer instead."
558
+ ),
559
+ placeholder: z
560
+ .string()
561
+ .optional()
562
+ .describe("Placeholder text for the free-text input field"),
563
+ timeout_seconds: z
564
+ .number()
565
+ .optional()
566
+ .describe("How long to wait for response (default: 300)"),
567
+ },
568
+ async (args) => {
569
+ const actionId = `ask_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
570
+ const timeout = (args.timeout_seconds || 300) * 1000;
571
+
572
+ const actionData = {
573
+ id: actionId,
574
+ type: "ask_user",
575
+ message: args.question,
576
+ options: args.options || [],
577
+ placeholder: args.placeholder || "",
578
+ created_at: new Date().toISOString(),
579
+ };
580
+
581
+ try {
582
+ await setActionRequest(taskId, actionData);
583
+ log.info(`Ask user ${actionId}: "${args.question.slice(0, 80)}..."`);
584
+
585
+ emitEvent(taskId, "user_action_request", actionData).catch(() => {});
586
+ // Emit waiting_for_user status so the web UI can show a waiting indicator
587
+ emitEvent(taskId, "status_change", {
588
+ status: "waiting_for_user",
589
+ message: args.question,
590
+ }).catch(() => {});
591
+
592
+ const startTime = Date.now();
593
+ const pollInterval = 2000;
594
+
595
+ while (Date.now() - startTime < timeout) {
596
+ const response = await pollActionResponse(taskId);
597
+ if (response && (!response.action_id || response.action_id === actionId)) {
598
+ // Response can be either an option click or free-text input
599
+ const actionKey = (response.action_key || "") as string;
600
+ const text = (response.text || "") as string;
601
+ const label = (response.label || actionKey || text) as string;
602
+ log.info(`User responded: "${label}"`);
603
+ return {
604
+ content: [
605
+ {
606
+ type: "text",
607
+ text: JSON.stringify({
608
+ status: "responded",
609
+ action_key: actionKey || "custom_input",
610
+ label,
611
+ text: text || label,
612
+ }),
613
+ },
614
+ ],
615
+ };
616
+ }
617
+
618
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
619
+ }
620
+
621
+ log.warn(`Ask user ${actionId} timed out after ${args.timeout_seconds || 300}s`);
622
+ return {
623
+ content: [
624
+ {
625
+ type: "text",
626
+ text: JSON.stringify({
627
+ status: "timeout",
628
+ message: "User did not respond within the timeout period.",
629
+ }),
630
+ },
631
+ ],
632
+ };
633
+ } catch (err) {
634
+ log.error(`ask_user failed: ${err}`);
635
+ return {
636
+ content: [
637
+ {
638
+ type: "text",
639
+ text: `Failed to ask user: ${err instanceof Error ? err.message : err}`,
640
+ },
641
+ ],
642
+ };
643
+ }
644
+ }
645
+ ),
646
+
647
+ // ── Job Automation Tools ──────────────────────────────────────
648
+
649
+ tool(
650
+ "job_run",
651
+ "Run a job by loading its goal and available skills as capabilities. " +
652
+ "You then decide dynamically which skills to use, in what order, and how to chain them based on what you discover. " +
653
+ "Use this when the user asks to run their job, or when a scheduled job fires.",
654
+ {
655
+ job_name: z
656
+ .string()
657
+ .describe("Name of the job to run (e.g. 'software-engineer', 'Frontend Dev')"),
658
+ },
659
+ async (args) => {
660
+ const runner = new JobRunner();
661
+ const job = await runner.loadJob(args.job_name);
662
+
663
+ if (!job) {
664
+ // Try to list available jobs
665
+ const jobs = await runner.listJobs();
666
+ const available =
667
+ jobs.length > 0
668
+ ? `Available jobs: ${jobs.map((j) => `"${j.name}" (${j.skillCount} skills)`).join(", ")}`
669
+ : "No jobs defined yet. Use skill_generate to create a job from a job description.";
670
+ return {
671
+ content: [{ type: "text", text: `Job "${args.job_name}" not found. ${available}` }],
672
+ };
673
+ }
674
+
675
+ if (job.skills.length === 0) {
676
+ return {
677
+ content: [
678
+ {
679
+ type: "text",
680
+ text: `Job "${args.job_name}" has no linked skills. Use skill_generate to add skills to this job.`,
681
+ },
682
+ ],
683
+ };
684
+ }
685
+
686
+ // Create a job run record (execution trace lives in conversation transcript)
687
+ const runId = await runner.createRun(job.jobId, {
688
+ sessionId: sessionId,
689
+ messageId: taskId,
690
+ triggerType: "manual",
691
+ });
692
+
693
+ if (!runId) {
694
+ // Still return the prompt even if tracking fails
695
+ log.debug("Failed to create job run record, proceeding without tracking");
696
+ }
697
+
698
+ // Build the structured prompt
699
+ const prompt = runner.buildJobPrompt(job, runId || "untracked");
700
+
701
+ log.info(
702
+ `Job "${args.job_name}" started with ${job.skills.length} skills (run: ${runId?.slice(0, 8) || "untracked"})`
703
+ );
704
+
705
+ return {
706
+ content: [{ type: "text", text: prompt }],
707
+ };
708
+ }
709
+ ),
710
+ tool(
711
+ "job_schedule",
712
+ "Schedule a job to run automatically on a recurring basis using a cron expression. " +
713
+ "For example, schedule your 'software-engineer' job to run every morning at 9am.",
714
+ {
715
+ job_name: z.string().describe("Name of the job to schedule"),
716
+ cron: z
717
+ .string()
718
+ .describe(
719
+ "Cron expression: 'minute hour day-of-month month day-of-week'. " +
720
+ "Examples: '0 9 * * *' (daily 9am), '0 9 * * 1-5' (weekdays 9am), '0 */2 * * *' (every 2 hours)"
721
+ ),
722
+ timezone: z
723
+ .string()
724
+ .optional()
725
+ .describe("Timezone (default: UTC). Examples: 'UTC', 'America/New_York'"),
726
+ schedule_name: z
727
+ .string()
728
+ .optional()
729
+ .describe("Custom name for this schedule (default: 'Job: <job_name>')"),
730
+ },
731
+ async (args) => {
732
+ const runner = new JobRunner();
733
+ const job = await runner.loadJob(args.job_name);
734
+
735
+ if (!job) {
736
+ return {
737
+ content: [
738
+ {
739
+ type: "text",
740
+ text: `Job "${args.job_name}" not found. Create it first with skill_generate.`,
741
+ },
742
+ ],
743
+ };
744
+ }
745
+
746
+ // Validate cron expression
747
+ try {
748
+ getNextRunTime(args.cron, args.timezone || "UTC");
749
+ } catch {
750
+ return {
751
+ content: [
752
+ {
753
+ type: "text",
754
+ text: `Invalid cron expression: "${args.cron}". Use format: "minute hour day-of-month month day-of-week"`,
755
+ },
756
+ ],
757
+ };
758
+ }
759
+
760
+ // Create the scheduled task with a job-run prompt
761
+ const name = args.schedule_name || `Job: ${args.job_name}`;
762
+ const prompt = `[JobRun: ${args.job_name}] Run the "${args.job_name}" job. Use job_run to execute it.`;
763
+ const tz = args.timezone || "UTC";
764
+
765
+ try {
766
+ const task = await createScheduledTask(name, prompt, args.cron, tz);
767
+
768
+ // Link the job_id to the scheduled task
769
+ await callMcpHandler("schedule.link_job", {
770
+ task_id: task.id,
771
+ job_id: job.jobId,
772
+ });
773
+
774
+ const nextRun = task.next_run_at
775
+ ? new Date(task.next_run_at).toLocaleString()
776
+ : "calculating...";
777
+
778
+ let response = `## Job Scheduled: ${args.job_name}\n\n`;
779
+ response += `- **Schedule:** ${args.cron} (${tz})\n`;
780
+ response += `- **Next run:** ${nextRun}\n`;
781
+ response += `- **Skills:** ${job.skills.length}\n\n`;
782
+ response += `The job "${args.job_name}" will automatically run on this schedule. `;
783
+ response += `Each run will execute ${job.skills.length} skills in sequence:\n`;
784
+ for (const skill of job.skills) {
785
+ const emoji = skill.skillEmoji ? `${skill.skillEmoji} ` : "";
786
+ response += ` - ${emoji}${skill.skillName}\n`;
787
+ }
788
+
789
+ log.success(`Job "${args.job_name}" scheduled: ${args.cron}`);
790
+ return { content: [{ type: "text", text: response }] };
791
+ } catch (err) {
792
+ return {
793
+ content: [
794
+ {
795
+ type: "text",
796
+ text: `Failed to schedule job: ${err instanceof Error ? err.message : err}`,
797
+ },
798
+ ],
799
+ };
800
+ }
801
+ }
802
+ ),
803
+ tool(
804
+ "job_status",
805
+ "Check the status and run history of a job. Shows recent executions, success rates, and details.",
806
+ {
807
+ job_name: z.string().optional().describe("Job name to check (omit for all jobs)"),
808
+ limit: z.number().optional().describe("Max number of runs to show (default: 5)"),
809
+ },
810
+ async (args) => {
811
+ const runner = new JobRunner();
812
+
813
+ // If no job specified, list all jobs
814
+ if (!args.job_name) {
815
+ const jobs = await runner.listJobs();
816
+ if (jobs.length === 0) {
817
+ return {
818
+ content: [
819
+ {
820
+ type: "text",
821
+ text: "No jobs defined. Use skill_generate to create a job from your job description.",
822
+ },
823
+ ],
824
+ };
825
+ }
826
+
827
+ let response = "## Your Jobs\n\n";
828
+ for (const job of jobs) {
829
+ response += `- **${job.name}** (${job.skillCount} skills): ${job.description.slice(0, 100)}\n`;
830
+ }
831
+ response += "\nUse `job_run` to execute a job, or `job_schedule` to automate it.";
832
+ return { content: [{ type: "text", text: response }] };
833
+ }
834
+
835
+ // Show run history for specific job
836
+ const runs = await runner.getRunHistory(args.job_name, args.limit || 5);
837
+
838
+ if (runs.length === 0) {
839
+ return {
840
+ content: [
841
+ {
842
+ type: "text",
843
+ text: `No runs found for job "${args.job_name}". Use job_run to execute it.`,
844
+ },
845
+ ],
846
+ };
847
+ }
848
+
849
+ let response = `## Job Status: ${args.job_name}\n\n`;
850
+ response += `### Recent Runs\n\n`;
851
+
852
+ for (const run of runs) {
853
+ const statusIcon =
854
+ run.status === "completed"
855
+ ? "+"
856
+ : run.status === "failed"
857
+ ? "x"
858
+ : run.status === "running"
859
+ ? "~"
860
+ : "-";
861
+ const date = new Date(run.startedAt).toLocaleString();
862
+ const duration = run.completedAt
863
+ ? `${Math.round((new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s`
864
+ : "in progress";
865
+
866
+ response += `[${statusIcon}] ${date} | ${run.triggerType} | ${duration}\n`;
867
+ if (run.summary) {
868
+ response += ` ${run.summary.slice(0, 100)}\n`;
869
+ }
870
+ }
871
+
872
+ // Stats
873
+ const total = runs.length;
874
+ const completed = runs.filter((r) => r.status === "completed").length;
875
+ const failed = runs.filter((r) => r.status === "failed").length;
876
+ response += `\n**Stats:** ${completed}/${total} successful`;
877
+ if (failed > 0) response += `, ${failed} failed`;
878
+ response += "\n";
879
+
880
+ return { content: [{ type: "text", text: response }] };
881
+ }
882
+ ),
883
+
884
+ // ── Credential Tools ──────────────────────────────────────────
885
+
886
+ tool(
887
+ "credential_get",
888
+ "Retrieve a locally stored credential by name. Returns the secret data (API keys, tokens, etc.) " +
889
+ "stored on the user's machine. Use this when a skill needs authentication or API access.",
890
+ {
891
+ name: z.string().describe("Credential name (e.g. 'amazon-login', 'openai-api-key')"),
892
+ },
893
+ async (args) => {
894
+ const store = getCredentialStore();
895
+ const credential = store.getByName(args.name);
896
+ if (!credential) {
897
+ const all = store.list();
898
+ const available =
899
+ all.length > 0
900
+ ? `Available credentials: ${all.map((m) => m.name).join(", ")}`
901
+ : "No credentials stored yet.";
902
+ return {
903
+ content: [
904
+ {
905
+ type: "text",
906
+ text: `Credential "${args.name}" not found. ${available}\nUse ask_user to request it from the user, or create it yourself (e.g. register an account), then store with credential_set.`,
907
+ },
908
+ ],
909
+ };
910
+ }
911
+
912
+ log.info(`Credential accessed: "${args.name}" (${credential.meta.type})`);
913
+ return {
914
+ content: [
915
+ {
916
+ type: "text",
917
+ text: JSON.stringify({
918
+ name: credential.meta.name,
919
+ type: credential.meta.type,
920
+ data: credential.data,
921
+ skill: credential.meta.skillName || null,
922
+ }),
923
+ },
924
+ ],
925
+ };
926
+ }
927
+ ),
928
+ tool(
929
+ "credential_set",
930
+ "Store a credential locally on the user's machine. The credential is encrypted at rest and " +
931
+ "never sent to any remote server. IMPORTANT: Always use this to persist credentials — both when " +
932
+ "receiving them from the user via ask_user AND when you generate new credentials yourself " +
933
+ "(e.g. registering an account, creating an API key, generating a token). This ensures credentials " +
934
+ "survive across sessions and don't need to be recreated.",
935
+ {
936
+ name: z.string().describe("Credential name (lowercase kebab-case, e.g. 'amazon-login')"),
937
+ type: z
938
+ .enum(["api_key", "oauth_token", "login", "secret", "custom"])
939
+ .describe("Credential type"),
940
+ data: z
941
+ .record(z.string(), z.string())
942
+ .describe(
943
+ 'Key-value pairs (e.g. { "username": "...", "password": "..." } or { "api_key": "..." })'
944
+ ),
945
+ skill_name: z.string().optional().describe("Associate with a specific skill"),
946
+ tags: z.array(z.string()).optional().describe("Tags for searchability"),
947
+ },
948
+ async (args) => {
949
+ const store = getCredentialStore();
950
+ const meta = store.save(args.name, args.type as CredentialType, args.data, {
951
+ skillName: args.skill_name,
952
+ tags: args.tags,
953
+ });
954
+
955
+ log.info(`Credential stored: "${args.name}" (${args.type})`);
956
+ return {
957
+ content: [
958
+ {
959
+ type: "text",
960
+ text: `Credential "${meta.name}" stored locally (type: ${meta.type}, id: ${meta.id}). It is encrypted and will persist across app restarts.`,
961
+ },
962
+ ],
963
+ };
964
+ }
965
+ ),
966
+ tool(
967
+ "credential_list",
968
+ "List all locally stored credentials (metadata only, no secrets). " +
969
+ "Use this to check what credentials are available before executing a skill.",
970
+ {
971
+ skill_name: z.string().optional().describe("Filter by skill name"),
972
+ type: z.string().optional().describe("Filter by credential type"),
973
+ },
974
+ async (args) => {
975
+ const store = getCredentialStore();
976
+ let results = store.list();
977
+
978
+ if (args.skill_name) {
979
+ results = results.filter((m) => m.skillName === args.skill_name);
980
+ }
981
+ if (args.type) {
982
+ results = results.filter((m) => m.type === args.type);
983
+ }
984
+
985
+ if (results.length === 0) {
986
+ const filter = args.skill_name ? ` for skill "${args.skill_name}"` : "";
987
+ return {
988
+ content: [{ type: "text", text: `No credentials found${filter}.` }],
989
+ };
990
+ }
991
+
992
+ let response = "## Stored Credentials\n\n";
993
+ for (const m of results) {
994
+ const skill = m.skillName ? ` [${m.skillName}]` : "";
995
+ const tags = m.tags.length > 0 ? ` (${m.tags.join(", ")})` : "";
996
+ response += `- **${m.name}** (${m.type})${skill}${tags}\n`;
997
+ }
998
+ return { content: [{ type: "text", text: response }] };
999
+ }
1000
+ ),
1001
+ tool(
1002
+ "credential_remove",
1003
+ "Remove a locally stored credential by name.",
1004
+ {
1005
+ name: z.string().describe("Credential name to remove"),
1006
+ },
1007
+ async (args) => {
1008
+ const store = getCredentialStore();
1009
+ const removed = store.removeByName(args.name);
1010
+ if (!removed) {
1011
+ return {
1012
+ content: [{ type: "text", text: `Credential "${args.name}" not found.` }],
1013
+ };
1014
+ }
1015
+ log.info(`Credential removed: "${args.name}"`);
1016
+ return {
1017
+ content: [
1018
+ { type: "text", text: `Credential "${args.name}" removed from local storage.` },
1019
+ ],
1020
+ };
1021
+ }
1022
+ ),
1023
+ ],
1024
+ });
1025
+ }
1026
+
1027
+ // ── Helper: persist job and link skills ────────────────────────────
1028
+
1029
+ async function saveJobToDb(
1030
+ jobName: string,
1031
+ jobDescription: string,
1032
+ createdSkillNames: string[]
1033
+ ): Promise<void> {
1034
+ try {
1035
+ const data = await callMcpHandler("job.save_with_skills", {
1036
+ job_name: jobName,
1037
+ job_description: jobDescription,
1038
+ skill_names: createdSkillNames,
1039
+ });
1040
+
1041
+ log.debug(
1042
+ `Job "${jobName}" saved via edge function (id: ${data}), ${createdSkillNames.length} skill(s) linked`
1043
+ );
1044
+ } catch (err) {
1045
+ log.debug(`saveJobToDb error: ${err}`);
1046
+ }
1047
+ }