ofiere-openclaw-plugin 4.46.0 → 4.48.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 +6 -3
- package/src/staffPersona.ts +61 -11
- package/src/tools.ts +249 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofiere-openclaw-plugin",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.48.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, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
|
|
18
|
+
import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, loadChiefAgentConfig, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
|
|
19
19
|
|
|
20
20
|
interface ToolResult {
|
|
21
21
|
content: Array<{ type: "text"; text: string }>;
|
|
@@ -453,8 +453,11 @@ export function registerAttachmentContextHook(args: {
|
|
|
453
453
|
if (subagentId) {
|
|
454
454
|
staffRow = await loadSubagentRow(supabase, userId, subagentId);
|
|
455
455
|
if (staffRow && staffRow.chief_agent_id === resolvedAgentId) {
|
|
456
|
-
const chiefDefaults = await
|
|
457
|
-
|
|
456
|
+
const [chiefDefaults, chiefConfig] = await Promise.all([
|
|
457
|
+
loadChiefStaffDefaults(supabase, userId, staffRow.chief_agent_id).catch(() => null),
|
|
458
|
+
loadChiefAgentConfig(supabase, userId, staffRow.chief_agent_id).catch(() => null),
|
|
459
|
+
]);
|
|
460
|
+
const resolved = resolveStaffModels(staffRow, chiefDefaults, chiefConfig);
|
|
458
461
|
staffPrefix = buildStaffPersonaBlock(staffRow, resolved) + "\n\n---\n\n";
|
|
459
462
|
api.logger?.debug?.(
|
|
460
463
|
`[ofiere-staff] subagent ${subagentId} (${staffRow.name}) reporting to ${resolvedAgentId} — persona injected; resolved models: ${JSON.stringify(resolved)}`,
|
package/src/staffPersona.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface DispatchContextRow {
|
|
|
74
74
|
attached_sop_ids: string[];
|
|
75
75
|
attached_framework_ids: string[];
|
|
76
76
|
task_id: string | null;
|
|
77
|
+
conversation_id: string | null;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export async function loadDispatchContextBySession(
|
|
@@ -84,7 +85,7 @@ export async function loadDispatchContextBySession(
|
|
|
84
85
|
if (!sessionKey) return null;
|
|
85
86
|
const { data, error } = await supabase
|
|
86
87
|
.from("dispatch_context")
|
|
87
|
-
.select("subagent_id, attached_sop_ids, attached_framework_ids, task_id")
|
|
88
|
+
.select("subagent_id, attached_sop_ids, attached_framework_ids, task_id, conversation_id")
|
|
88
89
|
.eq("user_id", userId)
|
|
89
90
|
.eq("session_key", sessionKey)
|
|
90
91
|
.order("created_at", { ascending: false })
|
|
@@ -100,6 +101,7 @@ export async function loadDispatchContextBySession(
|
|
|
100
101
|
? (data.attached_framework_ids as unknown[]).filter((x): x is string => typeof x === "string")
|
|
101
102
|
: [],
|
|
102
103
|
task_id: (data.task_id as string | null) ?? null,
|
|
104
|
+
conversation_id: (data.conversation_id as string | null) ?? null,
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
107
|
|
|
@@ -125,7 +127,7 @@ export interface ResolvedStaffModels {
|
|
|
125
127
|
tool_call_model: string | null;
|
|
126
128
|
function_call_model: string | null;
|
|
127
129
|
vision_model: string | null;
|
|
128
|
-
source: Record<StaffModelSlot, "staff" | "chief_default" | "chief_fallback" | null>;
|
|
130
|
+
source: Record<StaffModelSlot, "staff" | "chief_default" | "chief_primary" | "chief_fallback" | null>;
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
const SLOTS: StaffModelSlot[] = [
|
|
@@ -136,6 +138,17 @@ const SLOTS: StaffModelSlot[] = [
|
|
|
136
138
|
"vision_model",
|
|
137
139
|
];
|
|
138
140
|
|
|
141
|
+
// Cycle 7b BUGSHOOT-3 — chief's own ofie_agent_config row, used as the third
|
|
142
|
+
// fallback tier when neither the staff slot nor the chief STAFF MODEL slot is
|
|
143
|
+
// set. Mirrors the dispatcher's loadStaffPrimaryModel chain.
|
|
144
|
+
export interface ChiefAgentConfigRow {
|
|
145
|
+
agent_id: string;
|
|
146
|
+
primary_model: string | null;
|
|
147
|
+
coding_model: string | null;
|
|
148
|
+
tool_call_model: string | null;
|
|
149
|
+
vision_model: string | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
139
152
|
export async function loadChiefStaffDefaults(
|
|
140
153
|
supabase: SupabaseClient,
|
|
141
154
|
userId: string,
|
|
@@ -153,19 +166,42 @@ export async function loadChiefStaffDefaults(
|
|
|
153
166
|
return data as ChiefStaffDefaultsRow;
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
export async function loadChiefAgentConfig(
|
|
170
|
+
supabase: SupabaseClient,
|
|
171
|
+
userId: string,
|
|
172
|
+
chiefAgentId: string,
|
|
173
|
+
): Promise<ChiefAgentConfigRow | null> {
|
|
174
|
+
const { data, error } = await supabase
|
|
175
|
+
.from("ofie_agent_config")
|
|
176
|
+
.select("agent_id, primary_model, coding_model, tool_call_model, vision_model")
|
|
177
|
+
.eq("user_id", userId)
|
|
178
|
+
.eq("agent_id", chiefAgentId)
|
|
179
|
+
.maybeSingle();
|
|
180
|
+
if (error || !data) return null;
|
|
181
|
+
return data as ChiefAgentConfigRow;
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
/**
|
|
157
|
-
* Resolution chain at delegate_to_staff time:
|
|
158
|
-
* staff.<slot>
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
185
|
+
* Resolution chain at delegate_to_staff time (BUGSHOOT-3):
|
|
186
|
+
* 1. staff.<slot> (per-staff override)
|
|
187
|
+
* 2. chief_staff_defaults.<slot> (chief-level STAFF MODEL)
|
|
188
|
+
* 3. ofie_agent_config.<slot> (chief's OWN slot, NEW)
|
|
189
|
+
* 4. ofie_agent_config.primary_model (chief's primary, last-resort sub)
|
|
190
|
+
* 5. null (gateway picks gateway default)
|
|
191
|
+
*
|
|
192
|
+
* Tier 3 was added in BUGSHOOT-3 because the prior chain stopped at tier 2 and
|
|
193
|
+
* relied on an implicit "gateway default" — which is opaque and led to BUG 10
|
|
194
|
+
* when the staff override held a model id the gateway rejected. With tier 3,
|
|
195
|
+
* an empty staff slot deterministically inherits the chief's actual model.
|
|
162
196
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
197
|
+
* Tier 4 (slot → primary_model fallback) handles the common case where chief
|
|
198
|
+
* has only a primary_model set and no per-slot routing — a staff with
|
|
199
|
+
* coding_model blank still inherits chief's primary_model rather than null.
|
|
165
200
|
*/
|
|
166
201
|
export function resolveStaffModels(
|
|
167
202
|
staff: SubagentRow,
|
|
168
203
|
chiefDefaults: ChiefStaffDefaultsRow | null,
|
|
204
|
+
chiefConfig?: ChiefAgentConfigRow | null,
|
|
169
205
|
): ResolvedStaffModels {
|
|
170
206
|
const out: ResolvedStaffModels = {
|
|
171
207
|
primary_model: null,
|
|
@@ -181,15 +217,26 @@ export function resolveStaffModels(
|
|
|
181
217
|
vision_model: null,
|
|
182
218
|
},
|
|
183
219
|
};
|
|
220
|
+
const chiefPrimary = typeof chiefConfig?.primary_model === "string" && chiefConfig.primary_model.trim()
|
|
221
|
+
? chiefConfig.primary_model.trim()
|
|
222
|
+
: null;
|
|
184
223
|
for (const slot of SLOTS) {
|
|
185
224
|
const staffVal = (staff as any)[slot] as string | null | undefined;
|
|
186
225
|
const chiefVal = chiefDefaults ? (chiefDefaults as any)[slot] as string | null | undefined : null;
|
|
226
|
+
const chiefSlotVal = chiefConfig ? (chiefConfig as any)[slot] as string | null | undefined : null;
|
|
187
227
|
if (staffVal && staffVal.trim()) {
|
|
188
228
|
out[slot] = staffVal;
|
|
189
229
|
out.source[slot] = "staff";
|
|
190
230
|
} else if (chiefVal && chiefVal.trim()) {
|
|
191
231
|
out[slot] = chiefVal;
|
|
192
232
|
out.source[slot] = "chief_default";
|
|
233
|
+
} else if (chiefSlotVal && chiefSlotVal.trim()) {
|
|
234
|
+
out[slot] = chiefSlotVal;
|
|
235
|
+
out.source[slot] = "chief_primary";
|
|
236
|
+
} else if (chiefPrimary) {
|
|
237
|
+
// Sub-fallback: chief has a primary_model but no per-slot override.
|
|
238
|
+
out[slot] = chiefPrimary;
|
|
239
|
+
out.source[slot] = "chief_primary";
|
|
193
240
|
} else {
|
|
194
241
|
out[slot] = null;
|
|
195
242
|
out.source[slot] = "chief_fallback";
|
|
@@ -223,8 +270,11 @@ export function buildStaffPersonaBlock(
|
|
|
223
270
|
lines.push(``, `## Operating Instructions`, staff.instructions.trim());
|
|
224
271
|
}
|
|
225
272
|
if (resolved && (resolved.primary_model || resolved.coding_model || resolved.tool_call_model || resolved.function_call_model || resolved.vision_model)) {
|
|
226
|
-
const sourceLabel = (s: "staff" | "chief_default" | "chief_fallback" | null) =>
|
|
227
|
-
s === "staff" ? "per-staff override"
|
|
273
|
+
const sourceLabel = (s: "staff" | "chief_default" | "chief_primary" | "chief_fallback" | null) =>
|
|
274
|
+
s === "staff" ? "per-staff override"
|
|
275
|
+
: s === "chief_default" ? "chief STAFF MODEL"
|
|
276
|
+
: s === "chief_primary" ? "chief's own model"
|
|
277
|
+
: "inherits chief (gateway default)";
|
|
228
278
|
const rows: string[] = [];
|
|
229
279
|
if (resolved.primary_model) rows.push(`- primary: \`${resolved.primary_model}\` (${sourceLabel(resolved.source.primary_model)})`);
|
|
230
280
|
if (resolved.coding_model) rows.push(`- coding: \`${resolved.coding_model}\` (${sourceLabel(resolved.source.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,113 +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
|
-
// BUG 4 fix (cycle 7b BUGSHOOT-1): if the input string already carries
|
|
657
|
-
// an explicit UTC marker (Z, +00, +0000, +00:00, UTC), the time
|
|
658
|
-
// component is NOT user-local — skip the local→UTC subtraction.
|
|
659
|
-
// Without this, a UTC-Z input like "2026-05-10T16:25:00Z" got mistaken
|
|
660
|
-
// for local 16:25 and stored as 09:25Z (off by TZ_OFFSET_HOURS).
|
|
661
|
-
const isUtcInput = /Z$|[+-]00:?00$|UTC$/i.test(startDate.trim());
|
|
662
|
-
|
|
663
|
-
if (explicitScheduledTime) {
|
|
664
|
-
// Agent explicitly passed a scheduled_time — treat as user’s local time
|
|
665
|
-
const dateStr = parsedDate.toISOString().split("T")[0]; // YYYY-MM-DD
|
|
666
|
-
const [localH, localM] = explicitScheduledTime.split(":").map(Number);
|
|
667
|
-
const utcH = localH - TZ_OFFSET_HOURS;
|
|
668
|
-
const dt = new Date(`${dateStr}T00:00:00Z`);
|
|
669
|
-
dt.setUTCHours(utcH, localM, 0, 0);
|
|
670
|
-
nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
|
|
671
|
-
scheduledTimeFinal = explicitScheduledTime; // Store as user's local time
|
|
672
|
-
scheduledDateFinal = dateStr;
|
|
673
|
-
// Normalize start_date to ISO UTC for consistent downstream parsing
|
|
674
|
-
insertData.start_date = dt.toISOString();
|
|
675
|
-
} else if (isUtcInput) {
|
|
676
|
-
// Input is already UTC — no local→UTC conversion. Use parsedDate
|
|
677
|
-
// directly. scheduled_time is documented as user-local, so add the
|
|
678
|
-
// offset to recover the local-clock value.
|
|
679
|
-
nextRunAtEpoch = Math.floor(parsedDate.getTime() / 1000);
|
|
680
|
-
const localHour = (parsedDate.getUTCHours() + TZ_OFFSET_HOURS + 24) % 24;
|
|
681
|
-
scheduledTimeFinal = `${String(localHour).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`;
|
|
682
|
-
scheduledDateFinal = parsedDate.toISOString().split("T")[0];
|
|
683
|
-
insertData.start_date = parsedDate.toISOString();
|
|
684
|
-
} else if (hasTimeInfo) {
|
|
685
|
-
// start_date already contains time — assume it’s in the user’s local timezone
|
|
686
|
-
// Extract the local hour:minute from the string, NOT from UTC parsing
|
|
687
|
-
const timeMatch = startDate.match(/(\d{2}):(\d{2})/);
|
|
688
|
-
if (timeMatch) {
|
|
689
|
-
const localH = parseInt(timeMatch[1], 10);
|
|
690
|
-
const localM = parseInt(timeMatch[2], 10);
|
|
691
|
-
const dateStr = parsedDate.toISOString().split("T")[0];
|
|
692
|
-
const dt = new Date(`${dateStr}T00:00:00Z`);
|
|
693
|
-
dt.setUTCHours(localH - TZ_OFFSET_HOURS, localM, 0, 0);
|
|
694
|
-
nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
|
|
695
|
-
scheduledTimeFinal = `${String(localH).padStart(2, "0")}:${String(localM).padStart(2, "0")}`;
|
|
696
|
-
scheduledDateFinal = dateStr;
|
|
697
|
-
// Normalize start_date to ISO UTC
|
|
698
|
-
insertData.start_date = dt.toISOString();
|
|
699
|
-
} else {
|
|
700
|
-
// Can't extract time — fall back to UTC parsing
|
|
701
|
-
nextRunAtEpoch = Math.floor(parsedDate.getTime() / 1000);
|
|
702
|
-
scheduledTimeFinal = `${String(parsedDate.getUTCHours()).padStart(2, "0")}:${String(parsedDate.getUTCMinutes()).padStart(2, "0")}`;
|
|
703
|
-
scheduledDateFinal = parsedDate.toISOString().split("T")[0];
|
|
704
|
-
}
|
|
705
|
-
} else {
|
|
706
|
-
// Date only, no time — default to 09:00 user local time
|
|
707
|
-
const dateStr = parsedDate.toISOString().split("T")[0];
|
|
708
|
-
const dt = new Date(`${dateStr}T00:00:00Z`);
|
|
709
|
-
dt.setUTCHours(9 - TZ_OFFSET_HOURS, 0, 0, 0); // 09:00 local = UTC - offset
|
|
710
|
-
nextRunAtEpoch = Math.floor(dt.getTime() / 1000);
|
|
711
|
-
scheduledTimeFinal = "09:00"; // Stored as user's local time
|
|
712
|
-
scheduledDateFinal = dateStr;
|
|
713
|
-
// Normalize start_date to ISO UTC
|
|
714
|
-
insertData.start_date = dt.toISOString();
|
|
715
|
-
}
|
|
716
|
-
} else {
|
|
717
|
-
// Unparseable date — fallback to now + 60s
|
|
718
|
-
nextRunAtEpoch = Math.floor(Date.now() / 1000) + 60;
|
|
719
|
-
scheduledTimeFinal = "00:00";
|
|
720
|
-
scheduledDateFinal = new Date().toISOString().split("T")[0];
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Safety net: if computed time is in the past, schedule for now + 60s
|
|
773
|
+
let { nextRunAtEpoch } = normalized;
|
|
724
774
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
775
|
+
// Safety net: if computed time is in the past, schedule for now + 60s
|
|
725
776
|
if (nextRunAtEpoch <= nowEpoch) {
|
|
726
777
|
nextRunAtEpoch = nowEpoch + 60;
|
|
727
778
|
}
|
|
@@ -734,8 +785,8 @@ async function handleCreateTask(
|
|
|
734
785
|
subagent_id: subagentId,
|
|
735
786
|
title: params.title,
|
|
736
787
|
description: (params.description as string) || (params.instructions as string) || null,
|
|
737
|
-
scheduled_date:
|
|
738
|
-
scheduled_time:
|
|
788
|
+
scheduled_date: normalized.scheduledDateLocal,
|
|
789
|
+
scheduled_time: normalized.scheduledTimeLocal,
|
|
739
790
|
duration_minutes: 30,
|
|
740
791
|
recurrence_type: (params.recurrence_type as string) || "none",
|
|
741
792
|
recurrence_interval: (params.recurrence_interval as number) || 1,
|
|
@@ -6573,7 +6624,24 @@ function registerBrainExtractionHook(
|
|
|
6573
6624
|
// scoped memory note ("Staff X reported on task Y: ...") and POST a
|
|
6574
6625
|
// webhook so the dashboard can surface the report. The staff itself
|
|
6575
6626
|
// gets no memory entry (req 3 — staff has no memory of its own).
|
|
6576
|
-
|
|
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
|
+
}
|
|
6577
6645
|
if (staffDispatchSubagentId) {
|
|
6578
6646
|
(async () => {
|
|
6579
6647
|
try {
|
|
@@ -6586,9 +6654,12 @@ function registerBrainExtractionHook(
|
|
|
6586
6654
|
api.logger.warn?.(`[ofiere-staff-report] chief_mismatch (subagent ${staffDispatchSubagentId} reports to ${staff.chief_agent_id}, run was on ${resolvedAgentId}) — skipping report`);
|
|
6587
6655
|
return;
|
|
6588
6656
|
}
|
|
6657
|
+
// BUG 7 fix (BUGSHOOT-2): include dispatch_context.task_id as a
|
|
6658
|
+
// fallback now that metadata is dead on the wire.
|
|
6589
6659
|
const taskId =
|
|
6590
6660
|
ctx?.metadata?.task_id || ctx?.metadata?.dispatch?.task_id ||
|
|
6591
|
-
event?.metadata?.task_id || event?.context?.metadata?.task_id ||
|
|
6661
|
+
event?.metadata?.task_id || event?.context?.metadata?.task_id ||
|
|
6662
|
+
dispatchTaskIdFromCtx || null;
|
|
6592
6663
|
const excerpt = lastAssistant.slice(0, 1500);
|
|
6593
6664
|
const reportBody = taskId
|
|
6594
6665
|
? `Staff ${staff.name}${staff.role ? ` (${staff.role})` : ""} reported on task ${taskId}:\n\n${excerpt}`
|
|
@@ -6604,6 +6675,54 @@ function registerBrainExtractionHook(
|
|
|
6604
6675
|
});
|
|
6605
6676
|
api.logger.info?.(`[ofiere-staff-report] memory written for chief ${staff.chief_agent_id} from staff ${staff.name}`);
|
|
6606
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
|
+
|
|
6607
6726
|
// Best-effort webhook to dashboard (existing OPENCLAW_WEBHOOK_SECRET).
|
|
6608
6727
|
const webhookUrl = process.env.OFIERE_DASHBOARD_WEBHOOK_URL || "";
|
|
6609
6728
|
const webhookSecret = process.env.OPENCLAW_WEBHOOK_SECRET || "";
|
|
@@ -6639,6 +6758,24 @@ function registerBrainExtractionHook(
|
|
|
6639
6758
|
return;
|
|
6640
6759
|
}
|
|
6641
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
|
+
|
|
6642
6779
|
// ── Fast Stream: Write L1_focus + L2_episode in parallel ──
|
|
6643
6780
|
const rawContent = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
|
|
6644
6781
|
const immediateWrites: PromiseLike<any>[] = [];
|