ofiere-openclaw-plugin 4.45.0 → 4.47.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.
- package/package.json +1 -1
- package/src/attachments.ts +39 -3
- package/src/staffPersona.ts +46 -0
- package/src/tools.ts +276 -102
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofiere-openclaw-plugin",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.47.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin for Ofiere PM - 16 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, space file management, execution plan builder, SOP management, agent brain, talent management, and corporate frameworks",
|
|
6
6
|
"keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
|
package/src/attachments.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
invalidateAgentTier,
|
|
16
16
|
} from "./agent-tier.js";
|
|
17
17
|
import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
|
|
18
|
-
import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, resolveStaffModels, type SubagentRow } from "./staffPersona.js";
|
|
18
|
+
import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
|
|
19
19
|
|
|
20
20
|
interface ToolResult {
|
|
21
21
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -427,7 +427,27 @@ export function registerAttachmentContextHook(args: {
|
|
|
427
427
|
// Cycle 7b — staff persona injection. Only fires when subagent_id is
|
|
428
428
|
// present in dispatch metadata (task-dispatcher / scheduler / explicit
|
|
429
429
|
// dispatch params). Plain user chats with the chief never persona-swap.
|
|
430
|
-
|
|
430
|
+
//
|
|
431
|
+
// Cycle 7b BUGSHOOT-1 (BUG 2) — OpenClaw gateway core rejects unknown
|
|
432
|
+
// root key `metadata` on chat.send, so the dispatcher can't ride
|
|
433
|
+
// metadata through the wire. After the metadata path comes up empty,
|
|
434
|
+
// try the dispatch_context table keyed by sessionKey. The metadata
|
|
435
|
+
// path is preferred so a future gateway upgrade keeps working.
|
|
436
|
+
const sessionKey: string | null =
|
|
437
|
+
ctx?.sessionKey || ctx?.session_key ||
|
|
438
|
+
(typeof _event === "object" ? (_event?.sessionKey || _event?.context?.sessionKey || null) : null) ||
|
|
439
|
+
null;
|
|
440
|
+
let dispatchCtxRow: DispatchContextRow | null = null;
|
|
441
|
+
let subagentId = readDispatchSubagentId(ctx);
|
|
442
|
+
if (!subagentId && sessionKey) {
|
|
443
|
+
dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
|
|
444
|
+
if (dispatchCtxRow?.subagent_id) {
|
|
445
|
+
subagentId = dispatchCtxRow.subagent_id;
|
|
446
|
+
api.logger?.debug?.(
|
|
447
|
+
`[ofiere-staff] subagent_id resolved via dispatch_context (session=${sessionKey})`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
431
451
|
let staffPrefix = "";
|
|
432
452
|
let staffRow: SubagentRow | null = null;
|
|
433
453
|
if (subagentId) {
|
|
@@ -460,7 +480,23 @@ export function registerAttachmentContextHook(args: {
|
|
|
460
480
|
// can stash explicit `attached_sop_ids` / `attached_framework_ids` on the
|
|
461
481
|
// chat.send frame's metadata. When present, prefer them over the
|
|
462
482
|
// most-recent-conversation lookup and bypass cache (per-dispatch ids).
|
|
463
|
-
|
|
483
|
+
let dispatchIds = readDispatchAttachmentIds(ctx);
|
|
484
|
+
// Cycle 7b BUGSHOOT-1 (BUG 2) — DB-backed dispatch-context fallback for
|
|
485
|
+
// attachment ids. Same rationale as the subagent_id fallback above.
|
|
486
|
+
if (!dispatchIds.sopIds.length && !dispatchIds.fwIds.length && sessionKey) {
|
|
487
|
+
if (!dispatchCtxRow) {
|
|
488
|
+
dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
|
|
489
|
+
}
|
|
490
|
+
if (dispatchCtxRow && (dispatchCtxRow.attached_sop_ids.length || dispatchCtxRow.attached_framework_ids.length)) {
|
|
491
|
+
dispatchIds = {
|
|
492
|
+
sopIds: dispatchCtxRow.attached_sop_ids,
|
|
493
|
+
fwIds: dispatchCtxRow.attached_framework_ids,
|
|
494
|
+
};
|
|
495
|
+
api.logger?.debug?.(
|
|
496
|
+
`[ofiere-attach] dispatch ids via dispatch_context (session=${sessionKey}) sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
464
500
|
if (dispatchIds.sopIds.length || dispatchIds.fwIds.length) {
|
|
465
501
|
api.logger?.debug?.(
|
|
466
502
|
`[ofiere-attach] dispatch ids agent=${resolvedAgentId} sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
|
package/src/staffPersona.ts
CHANGED
|
@@ -59,6 +59,52 @@ export function readDispatchSubagentId(ctx: any, event?: any): string | null {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Cycle 7b BUGSHOOT-1 (BUG 2) — DB-backed dispatch context fallback.
|
|
63
|
+
// OpenClaw gateway core rejects unknown root key `metadata` on chat.send, so
|
|
64
|
+
// the task-dispatcher edge function can't ride dispatch metadata through the
|
|
65
|
+
// wire. The dispatcher writes a row to public.dispatch_context keyed by
|
|
66
|
+
// (user_id, session_key) before chat.send fires; this loader reads it back
|
|
67
|
+
// during before_prompt_build when the metadata path comes up empty.
|
|
68
|
+
//
|
|
69
|
+
// Returns the dispatch context for the most recent matching row, or null if
|
|
70
|
+
// none exists. The metadata path is preferred so a future gateway upgrade
|
|
71
|
+
// that natively accepts metadata keeps working with no flag day.
|
|
72
|
+
export interface DispatchContextRow {
|
|
73
|
+
subagent_id: string | null;
|
|
74
|
+
attached_sop_ids: string[];
|
|
75
|
+
attached_framework_ids: string[];
|
|
76
|
+
task_id: string | null;
|
|
77
|
+
conversation_id: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadDispatchContextBySession(
|
|
81
|
+
supabase: SupabaseClient,
|
|
82
|
+
userId: string,
|
|
83
|
+
sessionKey: string,
|
|
84
|
+
): Promise<DispatchContextRow | null> {
|
|
85
|
+
if (!sessionKey) return null;
|
|
86
|
+
const { data, error } = await supabase
|
|
87
|
+
.from("dispatch_context")
|
|
88
|
+
.select("subagent_id, attached_sop_ids, attached_framework_ids, task_id, conversation_id")
|
|
89
|
+
.eq("user_id", userId)
|
|
90
|
+
.eq("session_key", sessionKey)
|
|
91
|
+
.order("created_at", { ascending: false })
|
|
92
|
+
.limit(1)
|
|
93
|
+
.maybeSingle();
|
|
94
|
+
if (error || !data) return null;
|
|
95
|
+
return {
|
|
96
|
+
subagent_id: (data.subagent_id as string | null) ?? null,
|
|
97
|
+
attached_sop_ids: Array.isArray(data.attached_sop_ids)
|
|
98
|
+
? (data.attached_sop_ids as unknown[]).filter((x): x is string => typeof x === "string")
|
|
99
|
+
: [],
|
|
100
|
+
attached_framework_ids: Array.isArray(data.attached_framework_ids)
|
|
101
|
+
? (data.attached_framework_ids as unknown[]).filter((x): x is string => typeof x === "string")
|
|
102
|
+
: [],
|
|
103
|
+
task_id: (data.task_id as string | null) ?? null,
|
|
104
|
+
conversation_id: (data.conversation_id as string | null) ?? null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
62
108
|
export type StaffModelSlot =
|
|
63
109
|
| "primary_model"
|
|
64
110
|
| "coding_model"
|
package/src/tools.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
registerAttachmentContextHook,
|
|
20
20
|
} from "./attachments.js";
|
|
21
21
|
import { invalidateAgentTier } from "./agent-tier.js";
|
|
22
|
-
import { loadSubagentRow, readDispatchSubagentId } from "./staffPersona.js";
|
|
22
|
+
import { loadSubagentRow, readDispatchSubagentId, loadDispatchContextBySession, type DispatchContextRow } from "./staffPersona.js";
|
|
23
23
|
|
|
24
24
|
// ─── Tool result shape (matches OpenClaw SDK) ────────────────────────────────
|
|
25
25
|
|
|
@@ -458,12 +458,145 @@ async function handleListTasks(
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
// Cycle 7b BUGSHOOT-2 (BUG 5) — IANA timezone offset in hours. Works regardless
|
|
462
|
+
// of the VPS clock TZ because Intl.DateTimeFormat resolves the IANA zone
|
|
463
|
+
// directly without going through `new Date(local-string)` parsing.
|
|
464
|
+
function getTimezoneOffsetHours(tz: string): number {
|
|
465
|
+
try {
|
|
466
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
467
|
+
timeZone: tz,
|
|
468
|
+
timeZoneName: "shortOffset",
|
|
469
|
+
}).formatToParts(new Date());
|
|
470
|
+
const offsetPart = parts.find((p) => p.type === "timeZoneName");
|
|
471
|
+
const txt = offsetPart?.value || "GMT+0";
|
|
472
|
+
const m = txt.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
|
|
473
|
+
if (!m) return 0;
|
|
474
|
+
const hours = parseInt(m[1], 10);
|
|
475
|
+
const minutes = m[2] ? parseInt(m[2], 10) : 0;
|
|
476
|
+
return hours + (hours < 0 ? -minutes / 60 : minutes / 60);
|
|
477
|
+
} catch {
|
|
478
|
+
return 7;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Cycle 7b BUGSHOOT-2 (BUG 5) — load the caller's IANA timezone from their
|
|
483
|
+
// profile row. The dashboard writes it on sign-in (browser-detected). Falls
|
|
484
|
+
// back to the plugin-config default if unset/missing.
|
|
485
|
+
async function loadUserTimezone(
|
|
486
|
+
supabase: SupabaseClient,
|
|
487
|
+
userId: string,
|
|
488
|
+
fallback: string,
|
|
489
|
+
): Promise<string> {
|
|
490
|
+
try {
|
|
491
|
+
const { data } = await supabase
|
|
492
|
+
.from("profiles")
|
|
493
|
+
.select("timezone")
|
|
494
|
+
.eq("id", userId)
|
|
495
|
+
.maybeSingle();
|
|
496
|
+
const tz = typeof data?.timezone === "string" && data.timezone.trim()
|
|
497
|
+
? data.timezone.trim()
|
|
498
|
+
: null;
|
|
499
|
+
return tz || fallback;
|
|
500
|
+
} catch {
|
|
501
|
+
return fallback;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Cycle 7b BUGSHOOT-2 (BUG 5) — normalize a start_date string to UTC against
|
|
506
|
+
// the user's IANA timezone. Returns the canonical UTC ISO string, the epoch
|
|
507
|
+
// for next_run_at, and the local clock components for scheduler_events.
|
|
508
|
+
//
|
|
509
|
+
// Three input shapes are handled:
|
|
510
|
+
// - explicit UTC marker (Z, +00, +0000, +00:00, UTC) → no conversion
|
|
511
|
+
// - has time component, no UTC marker → treat as user-local, subtract offset
|
|
512
|
+
// - date only → default to 09:00 user-local, subtract offset
|
|
513
|
+
//
|
|
514
|
+
// Critical: parses date/time COMPONENTS from the raw string via regex, NOT via
|
|
515
|
+
// `new Date(str)`. JS Date parsing of naive strings is TZ-dependent on the
|
|
516
|
+
// host clock — the VPS may be WIB or UTC, and we cannot trust either.
|
|
517
|
+
function normalizeStartDate(
|
|
518
|
+
startDate: string,
|
|
519
|
+
explicitScheduledTime: string | undefined,
|
|
520
|
+
tzOffsetHours: number,
|
|
521
|
+
): {
|
|
522
|
+
startDateUTC: string;
|
|
523
|
+
nextRunAtEpoch: number;
|
|
524
|
+
scheduledTimeLocal: string;
|
|
525
|
+
scheduledDateLocal: string;
|
|
526
|
+
} | null {
|
|
527
|
+
const trimmed = startDate.trim();
|
|
528
|
+
const dateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
529
|
+
if (!dateMatch) return null;
|
|
530
|
+
const year = parseInt(dateMatch[1], 10);
|
|
531
|
+
const month = parseInt(dateMatch[2], 10);
|
|
532
|
+
const day = parseInt(dateMatch[3], 10);
|
|
533
|
+
|
|
534
|
+
const utcMarker = /Z$|[+-]00:?00$|UTC$/i.test(trimmed);
|
|
535
|
+
const timeMatch = trimmed.match(/[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
|
|
536
|
+
const offsetMin = Math.round(tzOffsetHours * 60);
|
|
537
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
538
|
+
|
|
539
|
+
// explicit scheduled_time: agent passed local time alongside a date.
|
|
540
|
+
if (explicitScheduledTime) {
|
|
541
|
+
const stMatch = explicitScheduledTime.match(/^(\d{2}):(\d{2})/);
|
|
542
|
+
const localH = stMatch ? parseInt(stMatch[1], 10) : 0;
|
|
543
|
+
const localM = stMatch ? parseInt(stMatch[2], 10) : 0;
|
|
544
|
+
const utcMs = Date.UTC(year, month - 1, day, localH, localM, 0) - offsetMin * 60 * 1000;
|
|
545
|
+
return {
|
|
546
|
+
startDateUTC: new Date(utcMs).toISOString(),
|
|
547
|
+
nextRunAtEpoch: Math.floor(utcMs / 1000),
|
|
548
|
+
scheduledTimeLocal: `${pad(localH)}:${pad(localM)}`,
|
|
549
|
+
scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (utcMarker && timeMatch) {
|
|
554
|
+
// Input is already UTC. Use components verbatim.
|
|
555
|
+
const utcH = parseInt(timeMatch[1], 10);
|
|
556
|
+
const utcM = parseInt(timeMatch[2], 10);
|
|
557
|
+
const utcS = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
|
|
558
|
+
const utcMs = Date.UTC(year, month - 1, day, utcH, utcM, utcS);
|
|
559
|
+
// Recover local clock by adding the user's offset.
|
|
560
|
+
const localMs = utcMs + offsetMin * 60 * 1000;
|
|
561
|
+
const local = new Date(localMs);
|
|
562
|
+
return {
|
|
563
|
+
startDateUTC: new Date(utcMs).toISOString(),
|
|
564
|
+
nextRunAtEpoch: Math.floor(utcMs / 1000),
|
|
565
|
+
scheduledTimeLocal: `${pad(local.getUTCHours())}:${pad(local.getUTCMinutes())}`,
|
|
566
|
+
scheduledDateLocal: `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (timeMatch) {
|
|
571
|
+
// Local-no-Z input: user-local time, subtract offset to get UTC.
|
|
572
|
+
const localH = parseInt(timeMatch[1], 10);
|
|
573
|
+
const localM = parseInt(timeMatch[2], 10);
|
|
574
|
+
const localS = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
|
|
575
|
+
const utcMs = Date.UTC(year, month - 1, day, localH, localM, localS) - offsetMin * 60 * 1000;
|
|
576
|
+
return {
|
|
577
|
+
startDateUTC: new Date(utcMs).toISOString(),
|
|
578
|
+
nextRunAtEpoch: Math.floor(utcMs / 1000),
|
|
579
|
+
scheduledTimeLocal: `${pad(localH)}:${pad(localM)}`,
|
|
580
|
+
scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Date-only input — default to 09:00 user-local.
|
|
585
|
+
const utcMs = Date.UTC(year, month - 1, day, 9, 0, 0) - offsetMin * 60 * 1000;
|
|
586
|
+
return {
|
|
587
|
+
startDateUTC: new Date(utcMs).toISOString(),
|
|
588
|
+
nextRunAtEpoch: Math.floor(utcMs / 1000),
|
|
589
|
+
scheduledTimeLocal: "09:00",
|
|
590
|
+
scheduledDateLocal: `${year}-${pad(month)}-${pad(day)}`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
461
594
|
async function handleCreateTask(
|
|
462
595
|
supabase: SupabaseClient,
|
|
463
596
|
userId: string,
|
|
464
597
|
resolveAgent: (id?: string) => Promise<string | null>,
|
|
465
598
|
params: Record<string, unknown>,
|
|
466
|
-
|
|
599
|
+
fallbackTimezone: string = "Asia/Jakarta",
|
|
467
600
|
): Promise<ToolResult> {
|
|
468
601
|
try {
|
|
469
602
|
if (!params.title) return err("Missing required field: title");
|
|
@@ -471,6 +604,11 @@ async function handleCreateTask(
|
|
|
471
604
|
const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
472
605
|
const now = new Date().toISOString();
|
|
473
606
|
|
|
607
|
+
// BUG 5 fix: resolve user's IANA timezone from their profile row. Plugin
|
|
608
|
+
// config default is only used when the profile row is missing/unset.
|
|
609
|
+
const timezone = await loadUserTimezone(supabase, userId, fallbackTimezone);
|
|
610
|
+
const TZ_OFFSET_HOURS = getTimezoneOffsetHours(timezone);
|
|
611
|
+
|
|
474
612
|
// Handle explicit "none"/"unassigned"
|
|
475
613
|
const rawAgentId = params.agent_id as string | undefined;
|
|
476
614
|
const isUnassigned =
|
|
@@ -576,6 +714,17 @@ async function handleCreateTask(
|
|
|
576
714
|
}
|
|
577
715
|
}
|
|
578
716
|
|
|
717
|
+
// BUG 5 fix (cycle 7b BUGSHOOT-2): normalize start_date BEFORE the INSERT
|
|
718
|
+
// so DB and tool-response carry the SAME canonical UTC ISO. Old code
|
|
719
|
+
// INSERTed the raw input then reassigned insertData.start_date afterward,
|
|
720
|
+
// which left the DB row with the unconverted value and the response with
|
|
721
|
+
// the converted one — confusing and unsafe for the scheduler.
|
|
722
|
+
const rawStartDate = (params.start_date as string) || null;
|
|
723
|
+
const normalized = rawStartDate
|
|
724
|
+
? normalizeStartDate(rawStartDate, params.scheduled_time as string | undefined, TZ_OFFSET_HOURS)
|
|
725
|
+
: null;
|
|
726
|
+
const canonicalStartDate = normalized?.startDateUTC ?? rawStartDate;
|
|
727
|
+
|
|
579
728
|
const insertData: Record<string, unknown> = {
|
|
580
729
|
id,
|
|
581
730
|
user_id: userId,
|
|
@@ -588,8 +737,8 @@ async function handleCreateTask(
|
|
|
588
737
|
priority: params.priority !== undefined ? params.priority : 1,
|
|
589
738
|
space_id: resolvedSpaceId,
|
|
590
739
|
folder_id: resolvedFolderId,
|
|
591
|
-
start_date:
|
|
592
|
-
due_date: (params.due_date as string) ||
|
|
740
|
+
start_date: canonicalStartDate,
|
|
741
|
+
due_date: (params.due_date as string) || canonicalStartDate || null,
|
|
593
742
|
tags: (params.tags as string[]) || [],
|
|
594
743
|
progress: 0,
|
|
595
744
|
sort_order: 0,
|
|
@@ -615,98 +764,15 @@ async function handleCreateTask(
|
|
|
615
764
|
}
|
|
616
765
|
|
|
617
766
|
// ── Auto-create scheduler event if task has a start_date ──────────────
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
// TIMEZONE: scheduled_time is treated as the user's LOCAL time (default WIB, UTC+7).
|
|
622
|
-
// We convert to UTC epoch for next_run_at so the edge function fires correctly.
|
|
623
|
-
const startDate = params.start_date as string | undefined;
|
|
767
|
+
// start_date is already normalized above. Scheduler event uses the same
|
|
768
|
+
// canonical UTC ISO + the local-clock components for the dashboard UI.
|
|
769
|
+
const startDate = rawStartDate;
|
|
624
770
|
const effectiveAgentId = (insertData.agent_id as string) || assignee;
|
|
625
|
-
|
|
626
|
-
// Resolve timezone offset from IANA timezone string (configurable per user)
|
|
627
|
-
const getTimezoneOffsetHours = (tz: string): number => {
|
|
628
|
-
try {
|
|
629
|
-
const now = new Date();
|
|
630
|
-
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
|
|
631
|
-
const tzStr = now.toLocaleString('en-US', { timeZone: tz });
|
|
632
|
-
const diffMs = new Date(tzStr).getTime() - new Date(utcStr).getTime();
|
|
633
|
-
return Math.round(diffMs / 3600000);
|
|
634
|
-
} catch {
|
|
635
|
-
return 7; // Fallback to WIB (UTC+7)
|
|
636
|
-
}
|
|
637
|
-
};
|
|
638
|
-
const TZ_OFFSET_HOURS = getTimezoneOffsetHours(timezone);
|
|
639
|
-
if (startDate && effectiveAgentId) {
|
|
771
|
+
if (startDate && effectiveAgentId && normalized) {
|
|
640
772
|
try {
|
|
641
|
-
|
|
642
|
-
// "2026-04-19" (date only)
|
|
643
|
-
// "2026-04-19T18:45:00" (local datetime)
|
|
644
|
-
// "2026-04-19 11:45:00+00" (Supabase timestamptz)
|
|
645
|
-
// "2026-04-19T11:45:00.000Z" (ISO UTC)
|
|
646
|
-
const parsedDate = new Date(startDate);
|
|
647
|
-
const explicitScheduledTime = params.scheduled_time as string | undefined;
|
|
648
|
-
|
|
649
|
-
let nextRunAtEpoch: number;
|
|
650
|
-
let scheduledTimeFinal: string;
|
|
651
|
-
let scheduledDateFinal: string;
|
|
652
|
-
|
|
653
|
-
if (!isNaN(parsedDate.getTime())) {
|
|
654
|
-
// Valid date — check if it includes a meaningful time component
|
|
655
|
-
const hasTimeInfo = /[T ]\d{2}:\d{2}/.test(startDate);
|
|
656
|
-
|
|
657
|
-
if (explicitScheduledTime) {
|
|
658
|
-
// Agent explicitly passed a scheduled_time — treat as user’s local time
|
|
659
|
-
const dateStr = parsedDate.toISOString().split("T")[0]; // YYYY-MM-DD
|
|
660
|
-
const [localH, localM] = explicitScheduledTime.split(":").map(Number);
|
|
661
|
-
const utcH = localH - TZ_OFFSET_HOURS;
|
|
662
|
-
const dt = new Date(`${dateStr}T00:00:00Z`);
|
|
663
|
-
dt.setUTCHours(utcH, localM, 0, 0);
|
|
664
|
-
nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
|
|
665
|
-
scheduledTimeFinal = explicitScheduledTime; // Store as user's local time
|
|
666
|
-
scheduledDateFinal = dateStr;
|
|
667
|
-
// Normalize start_date to ISO UTC for consistent downstream parsing
|
|
668
|
-
insertData.start_date = dt.toISOString();
|
|
669
|
-
} else if (hasTimeInfo) {
|
|
670
|
-
// start_date already contains time — assume it’s in the user’s local timezone
|
|
671
|
-
// Extract the local hour:minute from the string, NOT from UTC parsing
|
|
672
|
-
const timeMatch = startDate.match(/(\d{2}):(\d{2})/);
|
|
673
|
-
if (timeMatch) {
|
|
674
|
-
const localH = parseInt(timeMatch[1], 10);
|
|
675
|
-
const localM = parseInt(timeMatch[2], 10);
|
|
676
|
-
const dateStr = parsedDate.toISOString().split("T")[0];
|
|
677
|
-
const dt = new Date(`${dateStr}T00:00:00Z`);
|
|
678
|
-
dt.setUTCHours(localH - TZ_OFFSET_HOURS, localM, 0, 0);
|
|
679
|
-
nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
|
|
680
|
-
scheduledTimeFinal = `${String(localH).padStart(2, "0")}:${String(localM).padStart(2, "0")}`;
|
|
681
|
-
scheduledDateFinal = dateStr;
|
|
682
|
-
// Normalize start_date to ISO UTC
|
|
683
|
-
insertData.start_date = dt.toISOString();
|
|
684
|
-
} else {
|
|
685
|
-
// Can't extract time — fall back to UTC parsing
|
|
686
|
-
nextRunAtEpoch = Math.floor(parsedDate.getTime() / 1000);
|
|
687
|
-
scheduledTimeFinal = `${String(parsedDate.getUTCHours()).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`;
|
|
688
|
-
scheduledDateFinal = parsedDate.toISOString().split("T")[0];
|
|
689
|
-
}
|
|
690
|
-
} else {
|
|
691
|
-
// Date only, no time — default to 09:00 user local time
|
|
692
|
-
const dateStr = parsedDate.toISOString().split("T")[0];
|
|
693
|
-
const dt = new Date(`${dateStr}T00:00:00Z`);
|
|
694
|
-
dt.setUTCHours(9 - TZ_OFFSET_HOURS, 0, 0, 0); // 09:00 local = UTC - offset
|
|
695
|
-
nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
|
|
696
|
-
scheduledTimeFinal = "09:00"; // Stored as user's local time
|
|
697
|
-
scheduledDateFinal = dateStr;
|
|
698
|
-
// Normalize start_date to ISO UTC
|
|
699
|
-
insertData.start_date = dt.toISOString();
|
|
700
|
-
}
|
|
701
|
-
} else {
|
|
702
|
-
// Unparseable date — fallback to now + 60s
|
|
703
|
-
nextRunAtEpoch = Math.floor(Date.now() / 1000) + 60;
|
|
704
|
-
scheduledTimeFinal = "00:00";
|
|
705
|
-
scheduledDateFinal = new Date().toISOString().split("T")[0];
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Safety net: if computed time is in the past, schedule for now + 60s
|
|
773
|
+
let { nextRunAtEpoch } = normalized;
|
|
709
774
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
775
|
+
// Safety net: if computed time is in the past, schedule for now + 60s
|
|
710
776
|
if (nextRunAtEpoch <= nowEpoch) {
|
|
711
777
|
nextRunAtEpoch = nowEpoch + 60;
|
|
712
778
|
}
|
|
@@ -719,8 +785,8 @@ async function handleCreateTask(
|
|
|
719
785
|
subagent_id: subagentId,
|
|
720
786
|
title: params.title,
|
|
721
787
|
description: (params.description as string) || (params.instructions as string) || null,
|
|
722
|
-
scheduled_date:
|
|
723
|
-
scheduled_time:
|
|
788
|
+
scheduled_date: normalized.scheduledDateLocal,
|
|
789
|
+
scheduled_time: normalized.scheduledTimeLocal,
|
|
724
790
|
duration_minutes: 30,
|
|
725
791
|
recurrence_type: (params.recurrence_type as string) || "none",
|
|
726
792
|
recurrence_interval: (params.recurrence_interval as number) || 1,
|
|
@@ -5387,14 +5453,36 @@ async function handleCreateSubagent(supabase: SupabaseClient, userId: string, pa
|
|
|
5387
5453
|
return err(`Maximum ${MAX_SUBAGENTS_PER_CHIEF} subagents per department chief`);
|
|
5388
5454
|
}
|
|
5389
5455
|
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5456
|
+
// BUG 1 fix (cycle 7b BUGSHOOT-1): persist persona/model/MCP fields on
|
|
5457
|
+
// create. Schema accepts them (see SUBAGENT_INPUT_SCHEMA above) but the
|
|
5458
|
+
// previous insert dropped everything except identity/UI fields, forcing a
|
|
5459
|
+
// follow-up update_subagent. Mirror the field whitelist used in
|
|
5460
|
+
// handleUpdateSubagent so create/update stay in lockstep.
|
|
5461
|
+
const insertRow: Record<string, unknown> = {
|
|
5462
|
+
user_id: userId,
|
|
5463
|
+
chief_agent_id: chiefAgentId,
|
|
5464
|
+
name,
|
|
5465
|
+
role: (params.role as string) || "Staff",
|
|
5466
|
+
codename: (params.codename as string) ?? null,
|
|
5467
|
+
color_hex: (params.color_hex as string) || "#64748b",
|
|
5468
|
+
};
|
|
5469
|
+
const personaFields = [
|
|
5470
|
+
"system_prompt", "mission", "responsibilities", "instructions",
|
|
5471
|
+
"primary_model", "coding_model", "tool_call_model", "function_call_model", "vision_model",
|
|
5472
|
+
] as const;
|
|
5473
|
+
for (const key of personaFields) {
|
|
5474
|
+
if (key in params && params[key] !== undefined) insertRow[key] = params[key];
|
|
5475
|
+
}
|
|
5476
|
+
if ("mcp_server_ids" in params && Array.isArray(params.mcp_server_ids)) {
|
|
5477
|
+
insertRow.mcp_server_ids = (params.mcp_server_ids as unknown[]).filter(
|
|
5478
|
+
(x): x is string => typeof x === "string",
|
|
5479
|
+
);
|
|
5480
|
+
}
|
|
5393
5481
|
|
|
5394
5482
|
const { data, error } = await supabase
|
|
5395
5483
|
.from("agent_subagents")
|
|
5396
|
-
.insert(
|
|
5397
|
-
.select(
|
|
5484
|
+
.insert(insertRow)
|
|
5485
|
+
.select()
|
|
5398
5486
|
.single();
|
|
5399
5487
|
if (error) return err(error.message);
|
|
5400
5488
|
return ok({ subagent: data, message: `Subagent "${name}" created under chief ${chiefAgentId}` });
|
|
@@ -6536,7 +6624,24 @@ function registerBrainExtractionHook(
|
|
|
6536
6624
|
// scoped memory note ("Staff X reported on task Y: ...") and POST a
|
|
6537
6625
|
// webhook so the dashboard can surface the report. The staff itself
|
|
6538
6626
|
// gets no memory entry (req 3 — staff has no memory of its own).
|
|
6539
|
-
|
|
6627
|
+
//
|
|
6628
|
+
// Cycle 7b BUGSHOOT-2 (BUG 7) — metadata path is dead since BUG 2 made
|
|
6629
|
+
// the dispatcher stop sending `metadata` on chat.send. Fall back to
|
|
6630
|
+
// the dispatch_context row keyed by sessionKey before giving up.
|
|
6631
|
+
let staffDispatchSubagentId = readDispatchSubagentId(ctx, event);
|
|
6632
|
+
let dispatchCtxRow: DispatchContextRow | null = null;
|
|
6633
|
+
let dispatchTaskIdFromCtx: string | null = null;
|
|
6634
|
+
let dispatchConversationIdFromCtx: string | null = null;
|
|
6635
|
+
if (sessionKey) {
|
|
6636
|
+
dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
|
|
6637
|
+
if (dispatchCtxRow) {
|
|
6638
|
+
if (!staffDispatchSubagentId && dispatchCtxRow.subagent_id) {
|
|
6639
|
+
staffDispatchSubagentId = dispatchCtxRow.subagent_id;
|
|
6640
|
+
}
|
|
6641
|
+
dispatchTaskIdFromCtx = dispatchCtxRow.task_id;
|
|
6642
|
+
dispatchConversationIdFromCtx = dispatchCtxRow.conversation_id;
|
|
6643
|
+
}
|
|
6644
|
+
}
|
|
6540
6645
|
if (staffDispatchSubagentId) {
|
|
6541
6646
|
(async () => {
|
|
6542
6647
|
try {
|
|
@@ -6549,9 +6654,12 @@ function registerBrainExtractionHook(
|
|
|
6549
6654
|
api.logger.warn?.(`[ofiere-staff-report] chief_mismatch (subagent ${staffDispatchSubagentId} reports to ${staff.chief_agent_id}, run was on ${resolvedAgentId}) — skipping report`);
|
|
6550
6655
|
return;
|
|
6551
6656
|
}
|
|
6657
|
+
// BUG 7 fix (BUGSHOOT-2): include dispatch_context.task_id as a
|
|
6658
|
+
// fallback now that metadata is dead on the wire.
|
|
6552
6659
|
const taskId =
|
|
6553
6660
|
ctx?.metadata?.task_id || ctx?.metadata?.dispatch?.task_id ||
|
|
6554
|
-
event?.metadata?.task_id || event?.context?.metadata?.task_id ||
|
|
6661
|
+
event?.metadata?.task_id || event?.context?.metadata?.task_id ||
|
|
6662
|
+
dispatchTaskIdFromCtx || null;
|
|
6555
6663
|
const excerpt = lastAssistant.slice(0, 1500);
|
|
6556
6664
|
const reportBody = taskId
|
|
6557
6665
|
? `Staff ${staff.name}${staff.role ? ` (${staff.role})` : ""} reported on task ${taskId}:\n\n${excerpt}`
|
|
@@ -6567,6 +6675,54 @@ function registerBrainExtractionHook(
|
|
|
6567
6675
|
});
|
|
6568
6676
|
api.logger.info?.(`[ofiere-staff-report] memory written for chief ${staff.chief_agent_id} from staff ${staff.name}`);
|
|
6569
6677
|
|
|
6678
|
+
// BUG 9 fix (BUGSHOOT-2): mark task_dispatch_log row complete
|
|
6679
|
+
// with the assistant's response preview. Without this the log
|
|
6680
|
+
// stays at status='dispatched' forever, breaking dashboard
|
|
6681
|
+
// dispatch-history views and audit trails.
|
|
6682
|
+
if (taskId) {
|
|
6683
|
+
try {
|
|
6684
|
+
await supabase.from("task_dispatch_log")
|
|
6685
|
+
.update({
|
|
6686
|
+
status: "completed",
|
|
6687
|
+
response_preview: excerpt.slice(0, 500),
|
|
6688
|
+
completed_at: new Date().toISOString(),
|
|
6689
|
+
})
|
|
6690
|
+
.eq("task_id", taskId)
|
|
6691
|
+
.eq("status", "dispatched");
|
|
6692
|
+
} catch (logErr) {
|
|
6693
|
+
api.logger.debug?.(`[ofiere-staff-report] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
|
|
6694
|
+
}
|
|
6695
|
+
}
|
|
6696
|
+
|
|
6697
|
+
// BUG 6 fix (BUGSHOOT-2): mirror the assistant turn into the
|
|
6698
|
+
// dashboard's hidden conversation_messages so the dashboard can
|
|
6699
|
+
// render staff-run history. Dispatcher pre-created the
|
|
6700
|
+
// conversations row and stamped its id on dispatch_context.
|
|
6701
|
+
if (dispatchConversationIdFromCtx) {
|
|
6702
|
+
try {
|
|
6703
|
+
await supabase.from("conversation_messages").insert({
|
|
6704
|
+
conversation_id: dispatchConversationIdFromCtx,
|
|
6705
|
+
user_id: userId,
|
|
6706
|
+
role: "user",
|
|
6707
|
+
content: lastUser.slice(0, 4000),
|
|
6708
|
+
created_at: new Date(Date.now() - 1000).toISOString(),
|
|
6709
|
+
});
|
|
6710
|
+
await supabase.from("conversation_messages").insert({
|
|
6711
|
+
conversation_id: dispatchConversationIdFromCtx,
|
|
6712
|
+
user_id: userId,
|
|
6713
|
+
role: "assistant",
|
|
6714
|
+
content: excerpt,
|
|
6715
|
+
created_at: new Date().toISOString(),
|
|
6716
|
+
});
|
|
6717
|
+
await supabase.from("conversations")
|
|
6718
|
+
.update({ updated_at: new Date().toISOString() })
|
|
6719
|
+
.eq("id", dispatchConversationIdFromCtx)
|
|
6720
|
+
.eq("user_id", userId);
|
|
6721
|
+
} catch (cmErr) {
|
|
6722
|
+
api.logger.debug?.(`[ofiere-staff-report] conversation_messages insert failed: ${cmErr instanceof Error ? cmErr.message : String(cmErr)}`);
|
|
6723
|
+
}
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6570
6726
|
// Best-effort webhook to dashboard (existing OPENCLAW_WEBHOOK_SECRET).
|
|
6571
6727
|
const webhookUrl = process.env.OFIERE_DASHBOARD_WEBHOOK_URL || "";
|
|
6572
6728
|
const webhookSecret = process.env.OPENCLAW_WEBHOOK_SECRET || "";
|
|
@@ -6602,6 +6758,24 @@ function registerBrainExtractionHook(
|
|
|
6602
6758
|
return;
|
|
6603
6759
|
}
|
|
6604
6760
|
|
|
6761
|
+
// BUG 9 fix (BUGSHOOT-2): mark task_dispatch_log row complete for
|
|
6762
|
+
// non-staff scheduled dispatches too. Without this the log row stays
|
|
6763
|
+
// at 'dispatched' even after the chief finished the run.
|
|
6764
|
+
if (dispatchTaskIdFromCtx) {
|
|
6765
|
+
try {
|
|
6766
|
+
await supabase.from("task_dispatch_log")
|
|
6767
|
+
.update({
|
|
6768
|
+
status: "completed",
|
|
6769
|
+
response_preview: lastAssistant.slice(0, 500),
|
|
6770
|
+
completed_at: new Date().toISOString(),
|
|
6771
|
+
})
|
|
6772
|
+
.eq("task_id", dispatchTaskIdFromCtx)
|
|
6773
|
+
.eq("status", "dispatched");
|
|
6774
|
+
} catch (logErr) {
|
|
6775
|
+
api.logger.debug?.(`[ofiere-brain] dispatch_log update failed: ${logErr instanceof Error ? logErr.message : String(logErr)}`);
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
|
|
6605
6779
|
// ── Fast Stream: Write L1_focus + L2_episode in parallel ──
|
|
6606
6780
|
const rawContent = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
|
|
6607
6781
|
const immediateWrites: PromiseLike<any>[] = [];
|