ofiere-openclaw-plugin 4.24.0 → 4.26.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/prompt.ts +17 -3
  3. package/src/tools.ts +196 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofiere-openclaw-plugin",
3
- "version": "4.24.0",
3
+ "version": "4.26.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin for Ofiere PM - 13 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, space file management, execution plan builder, and SOP management",
6
6
  "keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
package/src/prompt.ts CHANGED
@@ -62,14 +62,20 @@ const TOOL_DOCS: Record<string, string> = {
62
62
  - Edge handles: condition edges use sourceHandle "condition-true"/"condition-false". Loop edges use "loop_body"/"done"
63
63
  - Variables: Use {{prev.nodeId.outputText}} for prior outputs, {{variables.key}} for stored variables`,
64
64
 
65
- OFIERE_NOTIFY_OPS: `- **OFIERE_NOTIFY_OPS** — Notifications & Channel Reports (action: "list", "mark_read", "mark_all_read", "delete", "send_report", "schedule_report", "list_schedules", "delete_schedule")
65
+ OFIERE_NOTIFY_OPS: `- **OFIERE_NOTIFY_OPS** — Notifications & Channel Reports (action: "list", "mark_read", "mark_all_read", "delete", "send_report", "get_task_detail", "schedule_report", "list_schedules", "delete_schedule")
66
66
  - list: Recent notifications. unread_only=true for unread only
67
67
  - mark_read: Mark one notification read by ID
68
68
  - mark_all_read: Mark all as read
69
- - send_report: Send a PM progress report to YOUR connected channels (Telegram, Discord, Slack, etc.)
69
+ - send_report: Send a numbered PM progress report to YOUR connected channels (Telegram, Discord, Slack, etc.)
70
70
  - Required: scope_type (space|folder|project|task|all), agent_id (YOUR name, e.g. "thalia")
71
71
  - Optional: scope_id, channel_types[] (filter to specific channels), include_completed
72
72
  - The report is automatically generated from current PM data and sent through YOUR channel bindings ONLY
73
+ - Tasks are numbered globally (1, 2, 3...) across Active/Pending/Done sections. Recurring tasks that executed recently appear in BOTH Done (today's run) and Pending (next cycle)
74
+ - get_task_detail: Get the FULL result/content of a task by its report number
75
+ - Required: task_number (1-indexed number from the report)
76
+ - Optional: scope_type (default all), scope_id
77
+ - Returns: complete task description (where execution results live), custom_fields, status, tags, and all metadata
78
+ - Use when user asks "details on number X", "what's task 2 result", "show me #3", etc.
73
79
  - schedule_report: Create a recurring/scheduled report
74
80
  - Required: scope_type, recurrence_type (hourly|daily|weekly|monthly), agent_id (YOUR name)
75
81
  - Optional: scope_id, scope_label, channel_types[], recurrence_time (HH:MM UTC, default 09:00), recurrence_interval, recurrence_days_of_week (e.g. "mon,wed,fri"), include_completed
@@ -202,7 +208,8 @@ ${toolDocs}
202
208
  - When task instructions or system prompts contain file references like @[filename](file:FILE_ID), use OFIERE_FILE_OPS action:"read_text_file" file_id:"FILE_ID" to read the file content. Do NOT ask the user for the file — retrieve it yourself.
203
209
  - Use OFIERE_FILE_OPS to create output files (reports, data, configs) in the Space Files explorer. Prefer create_text_file for text-based outputs.
204
210
  - To save task output as a file, call OFIERE_FILE_OPS action:"create_text_file" with the space_id from the task context.
205
- - CHANNEL REPORTS: When the user asks you to "send a report", "send progress", "update me on Telegram/Discord/Slack/WhatsApp", use OFIERE_NOTIFY_OPS action:"send_report" with agent_id set to YOUR name. ALWAYS include agent_id — without it the report will fail. The report is generated from live PM data and sent through YOUR connected channels ONLY — not other agents' channels.
211
+ - CHANNEL REPORTS: When the user asks you to "send a report", "send progress", "update me on Telegram/Discord/Slack/WhatsApp", use OFIERE_NOTIFY_OPS action:"send_report" with agent_id set to YOUR name. ALWAYS include agent_id — without it the report will fail. The report is generated from live PM data and sent through YOUR connected channels ONLY — not other agents' channels. Tasks are numbered (1, 2, 3...) for easy reference.
212
+ - TASK DRILL-DOWN: When the user asks "details on number X", "what's task X result", "show me #X", or any variation referencing a numbered task from a report, use OFIERE_NOTIFY_OPS action:"get_task_detail" with task_number set to the number they mentioned. The description field contains the actual execution result (e.g. scan findings, analysis output). Present the description content as the primary response — that IS the task result.
206
213
  - To set up recurring reports (e.g. "send me a daily report at 9am"), use OFIERE_NOTIFY_OPS action:"schedule_report" with scope_type, recurrence_type, and agent_id set to YOUR name.
207
214
  - If a report is too long for the channel's message limit, save the full report as a markdown file using OFIERE_FILE_OPS action:"create_text_file" and send a summary to the channel instead.
208
215
  - PLANNING WORKFLOW: Use OFIERE_PLAN_OPS to build complex multi-step execution flows BEFORE creating individual tasks. Build the plan → let the user review in the Planning Tab → execute when approved.
@@ -215,6 +222,13 @@ ${toolDocs}
215
222
  - When creating SOPs for department chiefs (Thalia=CMO, Ivy=COO, Daisy=CTO-Intel, Celia=CTO-Eng), tailor content to their domain expertise.
216
223
  - Prerequisites should be actionable checklist items. Success criteria should be measurable outcomes.
217
224
  - After creating an SOP, suggest the agent set it to "active" status when ready for execution.
225
+ - PLANNING GATE: Before creating ANY task with 3+ execution steps, a due_date, or when the user describes a multi-phase project, ALWAYS ask: "Should I create a Plan first so you can review the structure before I create individual tasks?" If the user says yes, use OFIERE_PLAN_OPS. If they say no or it's a simple one-shot task, proceed directly with OFIERE_TASK_OPS.
226
+ - TASK PLACEMENT — SOP-AWARE ROUTING: When creating a task, the system auto-assigns a PM space. But for FOLDER placement, follow this protocol:
227
+ 1. If the user explicitly provides space_id or folder_id, use those directly.
228
+ 2. If not, and you have active SOPs (loaded via 🔴 COMPLEX assessment), check if any SOP defines an operating structure or folder routing (e.g. "place marketing tasks in Marketing/Campaigns"). Follow the SOP's structure.
229
+ 3. If no SOP guidance exists, check the Space Files tab for an operating map/structure document using OFIERE_FILE_OPS action:"list_files". If found, read it with "read_text_file" and follow its routing rules.
230
+ 4. If no routing guidance exists at all, ask the user: "I can place this task in your default space root, or create a new folder/project for it. Which do you prefer?"
231
+ 5. Do NOT blindly dump all SOPs to check routing — smart-select only SOPs whose title/department matches the task domain.
218
232
 
219
233
  ## SOP PROTOCOL — Adaptive Complexity Assessment
220
234
 
package/src/tools.ts CHANGED
@@ -441,6 +441,61 @@ async function handleCreateTask(
441
441
  cf.assignees = [{ id: assignee, type: "agent" }];
442
442
  }
443
443
 
444
+ // ── Intelligent space_id resolution ──────────────────────────────────
445
+ // Priority: explicit param > agent's default_space_id > first existing space > auto-create
446
+ let resolvedSpaceId = (params.space_id as string) || null;
447
+ let resolvedFolderId = (params.folder_id as string) || null;
448
+ let spaceAutoCreated = false;
449
+
450
+ if (!resolvedSpaceId) {
451
+ try {
452
+ // 1. Check agent's configured default_space_id
453
+ if (assignee) {
454
+ const { data: agentRow } = await supabase
455
+ .from("agents")
456
+ .select("default_space_id")
457
+ .eq("id", assignee)
458
+ .eq("user_id", userId)
459
+ .single();
460
+ if (agentRow?.default_space_id) {
461
+ resolvedSpaceId = agentRow.default_space_id;
462
+ }
463
+ }
464
+
465
+ // 2. Fallback: use the first existing space for this user
466
+ if (!resolvedSpaceId) {
467
+ const { data: existingSpaces } = await supabase
468
+ .from("pm_spaces")
469
+ .select("id")
470
+ .eq("user_id", userId)
471
+ .order("created_at", { ascending: true })
472
+ .limit(1);
473
+ if (existingSpaces && existingSpaces.length > 0) {
474
+ resolvedSpaceId = existingSpaces[0].id;
475
+ }
476
+ }
477
+
478
+ // 3. Nuclear fallback: auto-create a space
479
+ if (!resolvedSpaceId) {
480
+ const newSpaceId = crypto.randomUUID();
481
+ const { error: spaceErr } = await supabase.from("pm_spaces").insert({
482
+ id: newSpaceId,
483
+ user_id: userId,
484
+ name: "Operations",
485
+ icon: "🏢",
486
+ icon_color: "#FF6D29",
487
+ sort_order: 0,
488
+ });
489
+ if (!spaceErr) {
490
+ resolvedSpaceId = newSpaceId;
491
+ spaceAutoCreated = true;
492
+ }
493
+ }
494
+ } catch {
495
+ // Non-fatal: task will still be created, just without space context
496
+ }
497
+ }
498
+
444
499
  const insertData: Record<string, unknown> = {
445
500
  id,
446
501
  user_id: userId,
@@ -450,8 +505,8 @@ async function handleCreateTask(
450
505
  assignee_type: "agent",
451
506
  status: (params.status as string) || "PENDING",
452
507
  priority: params.priority !== undefined ? params.priority : 1,
453
- space_id: (params.space_id as string) || null,
454
- folder_id: (params.folder_id as string) || null,
508
+ space_id: resolvedSpaceId,
509
+ folder_id: resolvedFolderId,
455
510
  start_date: (params.start_date as string) || null,
456
511
  due_date: (params.due_date as string) || (params.start_date as string) || null,
457
512
  tags: (params.tags as string[]) || [],
@@ -578,6 +633,17 @@ async function handleCreateTask(
578
633
  id,
579
634
  message: `Task "${params.title}" created and assigned to ${assignee || "no one"}${extrasStr}`,
580
635
  task: insertData,
636
+ spacePlacement: resolvedSpaceId
637
+ ? {
638
+ space_id: resolvedSpaceId,
639
+ auto_created: spaceAutoCreated,
640
+ note: spaceAutoCreated
641
+ ? 'A new "Operations" space was auto-created because no PM spaces existed.'
642
+ : resolvedFolderId
643
+ ? `Placed in folder ${resolvedFolderId}`
644
+ : "Placed in space root. To organize, check your SOPs/operating structure for folder routing, or specify folder_id.",
645
+ }
646
+ : undefined,
581
647
  scheduledExecution: didSchedule ? `Will auto-execute on ${startDate}` : undefined,
582
648
  recurrence: recurrenceInfo,
583
649
  });
@@ -2156,9 +2222,13 @@ function registerNotifyOps(
2156
2222
  `- "mark_read": Mark one as read. Required: id\n` +
2157
2223
  `- "mark_all_read": Mark all as read\n` +
2158
2224
  `- "delete": Delete a notification. Required: id\n` +
2159
- `- "send_report": Send a PM progress report to YOUR connected channels (Telegram, Discord, etc.)\n` +
2225
+ `- "send_report": Send a numbered PM progress report to YOUR connected channels (Telegram, Discord, etc.)\n` +
2160
2226
  ` Required: scope_type (space|folder|project|task|all), agent_id (YOUR name e.g. "thalia")\n` +
2161
2227
  ` Optional: scope_id, channel_types[] (filter specific channels), include_completed (default true)\n` +
2228
+ ` Tasks are numbered globally (1, 2, 3...) across Active/Pending/Done sections for easy reference\n` +
2229
+ `- "get_task_detail": Get the FULL result/content of a task by its report number. Use when user asks "details on number X"\n` +
2230
+ ` Required: task_number (1-indexed from the report). Optional: scope_type, scope_id (defaults to all)\n` +
2231
+ ` Returns the complete task description, execution results, status, and metadata\n` +
2162
2232
  `- "schedule_report": Create a recurring/scheduled report\n` +
2163
2233
  ` Required: scope_type, recurrence_type (hourly|daily|weekly|monthly), agent_id (YOUR name)\n` +
2164
2234
  ` Optional: scope_id, scope_label, channel_types[], recurrence_time (HH:MM UTC, default 09:00),\n` +
@@ -2169,10 +2239,11 @@ function registerNotifyOps(
2169
2239
  type: "object",
2170
2240
  required: ["action"],
2171
2241
  properties: {
2172
- action: { type: "string", enum: ["list", "mark_read", "mark_all_read", "delete", "send_report", "schedule_report", "list_schedules", "delete_schedule"] },
2242
+ action: { type: "string", enum: ["list", "mark_read", "mark_all_read", "delete", "send_report", "get_task_detail", "schedule_report", "list_schedules", "delete_schedule"] },
2173
2243
  agent_id: { type: "string", description: "Your agent name or ID (required for send_report/schedule_report)" },
2174
2244
  id: { type: "string", description: "Notification ID" },
2175
2245
  schedule_id: { type: "string", description: "Schedule ID (for delete_schedule)" },
2246
+ task_number: { type: "number", description: "1-indexed task number from the report (for get_task_detail)" },
2176
2247
  unread_only: { type: "boolean", description: "Only show unread" },
2177
2248
  limit: { type: "number", description: "Max results (default 50)" },
2178
2249
  scope_type: { type: "string", enum: ["space", "folder", "project", "task", "all"], description: "Report scope" },
@@ -2257,6 +2328,69 @@ function registerNotifyOps(
2257
2328
  });
2258
2329
  }
2259
2330
 
2331
+ // ── GET TASK DETAIL — Drill into task result by report number ──
2332
+ case "get_task_detail": {
2333
+ const taskNumber = params.task_number as number;
2334
+ if (!taskNumber || taskNumber < 1) return err("Missing required: task_number (1-indexed number from the report)");
2335
+ const detailScopeType = (params.scope_type as string) || "all";
2336
+ const detailScopeId = params.scope_id as string | undefined;
2337
+
2338
+ // Re-run the same query the report uses to get consistent ordering
2339
+ let dq = supabase.from("tasks").select("id, title, description, status, priority, progress, completed_at, due_date, updated_at, custom_fields, tags, agent_id")
2340
+ .eq("user_id", userId).is("parent_task_id", null);
2341
+ if (detailScopeType === "space" && detailScopeId) dq = dq.eq("space_id", detailScopeId);
2342
+ else if (detailScopeType === "folder" && detailScopeId) dq = dq.eq("folder_id", detailScopeId);
2343
+ else if (detailScopeType === "project" && detailScopeId) dq = dq.eq("project_id", detailScopeId);
2344
+ else if (detailScopeType === "task" && detailScopeId) dq = dq.eq("id", detailScopeId);
2345
+
2346
+ const { data: detailTasks } = await dq.order("priority", { ascending: false }).limit(40);
2347
+ const dtAll = detailTasks || [];
2348
+
2349
+ // Build same ordered list as report: Active → Pending → Done
2350
+ const dtInProgress = dtAll.filter((t: any) => t.status === "IN_PROGRESS" || t.status === "RUNNING");
2351
+ const dtPending = dtAll.filter((t: any) => t.status === "PENDING" || t.status === "NEW");
2352
+ const dtCompleted = dtAll.filter((t: any) => t.status === "DONE");
2353
+ const dtRecentThreshold = 24 * 60 * 60 * 1000;
2354
+ const dtNowMs = Date.now();
2355
+ const dtRecentlyExecuted = dtPending.filter((t: any) =>
2356
+ t.description && t.description.length > 50 &&
2357
+ t.updated_at && (dtNowMs - new Date(t.updated_at).getTime()) < dtRecentThreshold
2358
+ );
2359
+
2360
+ // Same ordering as report formatter
2361
+ const dtOrdered: any[] = [];
2362
+ const dtSeen = new Set<string>();
2363
+ const dtAdd = (t: any) => { if (!dtSeen.has(t.id)) { dtSeen.add(t.id); dtOrdered.push(t); } };
2364
+ for (const t of dtInProgress.slice(0, 8)) dtAdd(t);
2365
+ for (const t of dtPending.slice(0, 6)) dtAdd(t);
2366
+ for (const t of dtRecentlyExecuted) dtAdd(t);
2367
+ for (const t of dtCompleted.slice(0, 5)) dtAdd(t);
2368
+
2369
+ if (taskNumber > dtOrdered.length) {
2370
+ return err(`Task #${taskNumber} not found. The report has ${dtOrdered.length} numbered task(s). Valid range: 1-${dtOrdered.length}.`);
2371
+ }
2372
+
2373
+ const target = dtOrdered[taskNumber - 1];
2374
+ const isRecurring = dtRecentlyExecuted.some((r: any) => r.id === target.id);
2375
+
2376
+ return ok({
2377
+ task_number: taskNumber,
2378
+ task_id: target.id,
2379
+ title: target.title,
2380
+ status: target.status,
2381
+ priority: target.priority === 3 ? "CRITICAL" : target.priority === 2 ? "HIGH" : target.priority === 1 ? "MEDIUM" : "LOW",
2382
+ progress: target.progress,
2383
+ is_recurring_today: isRecurring,
2384
+ description: target.description || "(no description)",
2385
+ custom_fields: target.custom_fields || {},
2386
+ tags: target.tags || [],
2387
+ agent_id: target.agent_id,
2388
+ due_date: target.due_date,
2389
+ completed_at: target.completed_at,
2390
+ updated_at: target.updated_at,
2391
+ });
2392
+ }
2393
+
2260
2394
  // ── SCHEDULE REPORT — Create recurring report ──
2261
2395
  case "schedule_report": {
2262
2396
  const scopeType = (params.scope_type as string) || "all";
@@ -2319,7 +2453,7 @@ function registerNotifyOps(
2319
2453
  }
2320
2454
 
2321
2455
  default:
2322
- return err(`Unknown action "${action}". Valid: list, mark_read, mark_all_read, delete, send_report, schedule_report, list_schedules, delete_schedule`);
2456
+ return err(`Unknown action "${action}". Valid: list, mark_read, mark_all_read, delete, send_report, get_task_detail, schedule_report, list_schedules, delete_schedule`);
2323
2457
  }
2324
2458
  },
2325
2459
  });
@@ -2336,7 +2470,7 @@ async function generatePMReportDirect(
2336
2470
  includeCompleted: boolean,
2337
2471
  ): Promise<{ report: string; scopeLabel: string; taskCount: number }> {
2338
2472
  let scopeLabel = "All Projects";
2339
- let query = supabase.from("tasks").select("id, title, status, priority, progress, completed_at, due_date")
2473
+ let query = supabase.from("tasks").select("id, title, status, priority, progress, completed_at, due_date, description, updated_at")
2340
2474
  .eq("user_id", userId).is("parent_task_id", null);
2341
2475
 
2342
2476
  if (scopeType === "space" && scopeId) {
@@ -2367,24 +2501,53 @@ async function generatePMReportDirect(
2367
2501
  .limit(1).single();
2368
2502
  if (agentRow) agentName = agentRow.name || agentRow.codename || agentId;
2369
2503
 
2504
+ // Detect recently-executed recurring tasks: PENDING but updated within 24h with content
2505
+ const recentThreshold = 24 * 60 * 60 * 1000;
2506
+ const nowMs = Date.now();
2507
+ const isRecentlyExecuted = (t: any) =>
2508
+ (t.status === "PENDING" || t.status === "NEW") &&
2509
+ t.description && t.description.length > 50 &&
2510
+ t.updated_at && (nowMs - new Date(t.updated_at).getTime()) < recentThreshold;
2511
+
2512
+ const recentlyExecuted = all.filter(isRecentlyExecuted);
2370
2513
  const completed = all.filter((t: any) => t.status === "DONE");
2371
2514
  const inProgress = all.filter((t: any) => t.status === "IN_PROGRESS" || t.status === "RUNNING");
2372
2515
  const pending = all.filter((t: any) => t.status === "PENDING" || t.status === "NEW");
2373
2516
  const failed = all.filter((t: any) => t.status === "FAILED");
2374
2517
 
2518
+ // Build ordered task list for consistent numbering (same order as report sections)
2519
+ const orderedTaskIds: string[] = [];
2520
+ const taskNumMap = new Map<string, number>();
2521
+ const assignNum = (t: any) => {
2522
+ if (!taskNumMap.has(t.id)) {
2523
+ orderedTaskIds.push(t.id);
2524
+ taskNumMap.set(t.id, orderedTaskIds.length);
2525
+ }
2526
+ return taskNumMap.get(t.id)!;
2527
+ };
2528
+
2529
+ // Pre-assign numbers in display order: Active → Pending → Recently Executed → Done
2530
+ for (const t of inProgress.slice(0, 8)) assignNum(t);
2531
+ for (const t of pending.slice(0, 6)) assignNum(t);
2532
+ for (const t of recentlyExecuted) assignNum(t);
2533
+ for (const t of completed.slice(0, 5)) assignNum(t);
2534
+
2535
+ const doneCount = completed.length + recentlyExecuted.length;
2536
+
2375
2537
  const lines: string[] = [];
2376
2538
  lines.push(`📊 Project Report — ${scopeLabel}`);
2377
2539
  lines.push(`Agent: ${agentName}`);
2378
2540
  lines.push("");
2379
2541
  lines.push("━━━ Summary ━━━");
2380
- lines.push(`✅ ${completed.length} | 🔄 ${inProgress.length} | ⏳ ${pending.length} | ❌ ${failed.length}`);
2542
+ lines.push(`✅ ${doneCount} | 🔄 ${inProgress.length} | ⏳ ${pending.length} | ❌ ${failed.length}`);
2381
2543
 
2382
2544
  if (inProgress.length > 0) {
2383
2545
  lines.push("");
2384
2546
  lines.push("━━━ Active ━━━");
2385
2547
  for (const t of inProgress.slice(0, 8)) {
2548
+ const n = taskNumMap.get(t.id)!;
2386
2549
  const pl = t.priority === 3 ? "CRIT" : t.priority === 2 ? "HIGH" : t.priority === 1 ? "MED" : "LOW";
2387
- lines.push(`🔄 [${pl}] ${t.title}${t.progress ? ` — ${t.progress}%` : ""}`);
2550
+ lines.push(`${n}. 🔄 [${pl}] ${t.title}${t.progress ? ` — ${t.progress}%` : ""}`);
2388
2551
  }
2389
2552
  if (inProgress.length > 8) lines.push(`... +${inProgress.length - 8} more`);
2390
2553
  }
@@ -2393,26 +2556,41 @@ async function generatePMReportDirect(
2393
2556
  lines.push("");
2394
2557
  lines.push("━━━ Pending ━━━");
2395
2558
  for (const t of pending.slice(0, 6)) {
2559
+ const n = taskNumMap.get(t.id)!;
2396
2560
  const pl = t.priority === 3 ? "CRIT" : t.priority === 2 ? "HIGH" : t.priority === 1 ? "MED" : "LOW";
2397
- lines.push(`⏳ [${pl}] ${t.title}`);
2561
+ const suffix = isRecentlyExecuted(t) ? " (next cycle)" : "";
2562
+ lines.push(`${n}. ⏳ [${pl}] ${t.title}${suffix}`);
2398
2563
  }
2399
2564
  if (pending.length > 6) lines.push(`... +${pending.length - 6} more`);
2400
2565
  }
2401
2566
 
2402
- if (includeCompleted && completed.length > 0) {
2567
+ // Done section: recently-executed recurring tasks + regular completed
2568
+ const doneItems: { task: any; isRecurring: boolean }[] = [
2569
+ ...recentlyExecuted.map((t: any) => ({ task: t, isRecurring: true })),
2570
+ ...(includeCompleted ? completed.filter((t: any) => t.completed_at)
2571
+ .sort((a: any, b: any) => new Date(b.completed_at).getTime() - new Date(a.completed_at).getTime())
2572
+ .slice(0, 5).map((t: any) => ({ task: t, isRecurring: false })) : []),
2573
+ ];
2574
+
2575
+ if (doneItems.length > 0) {
2403
2576
  lines.push("");
2404
2577
  lines.push("━━━ Done ━━━");
2405
- const recent = completed.filter((t: any) => t.completed_at)
2406
- .sort((a: any, b: any) => new Date(b.completed_at).getTime() - new Date(a.completed_at).getTime())
2407
- .slice(0, 5);
2408
- for (const t of recent) {
2409
- const diff = Date.now() - new Date(t.completed_at).getTime();
2410
- const mins = Math.floor(diff / 60000);
2411
- const ago = mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
2412
- lines.push(`✅ ${t.title} (${ago})`);
2578
+ for (const { task: t, isRecurring } of doneItems) {
2579
+ const n = assignNum(t);
2580
+ if (isRecurring) {
2581
+ lines.push(`${n}. ✅ ${t.title} (today's run)`);
2582
+ } else {
2583
+ const diff = Date.now() - new Date(t.completed_at).getTime();
2584
+ const mins = Math.floor(diff / 60000);
2585
+ const ago = mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
2586
+ lines.push(`${n}. ✅ ${t.title} (${ago})`);
2587
+ }
2413
2588
  }
2414
2589
  }
2415
2590
 
2591
+ lines.push("");
2592
+ lines.push(`💡 Reply "details on #N" for full task result`);
2593
+
2416
2594
  const now = new Date();
2417
2595
  lines.push("");
2418
2596
  lines.push(`Generated: ${now.toISOString().replace("T", " ").slice(0, 16)} UTC`);