ofiere-openclaw-plugin 4.55.0 → 4.56.1

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.
@@ -1,601 +1,601 @@
1
- // src/attachments.ts — Plugin-side SOP/Framework attachment plumbing.
2
- // Three responsibilities:
3
- // 1. before_prompt_build hook: read the most-recent conversation for the
4
- // caller's agent and inject any attached SOPs/Frameworks as system
5
- // context (cached per-agent like the brain hook).
6
- // 2. propose_attach: validate target tier + doc kind, return token-cost
7
- // summary + HMAC confirmation token (5 min ttl).
8
- // 3. commit_attach: re-verify token, write attachment ids to the row.
9
-
10
- import type { SupabaseClient } from "@supabase/supabase-js";
11
- import { renderAttachmentBlock } from "./sop-render.js";
12
- import {
13
- resolveTargetTier,
14
- isDocKindValidForTier,
15
- invalidateAgentTier,
16
- } from "./agent-tier.js";
17
- import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
18
- import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, loadChiefAgentConfig, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
19
-
20
- interface ToolResult {
21
- content: Array<{ type: "text"; text: string }>;
22
- }
23
- function ok(data: unknown): ToolResult {
24
- return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
25
- }
26
- function err(message: string): ToolResult {
27
- return { content: [{ type: "text" as const, text: `Error: ${message}` }] };
28
- }
29
-
30
- export type TargetKind = "conversation" | "task" | "scheduler_event" | "subagent";
31
- export type DocKind = "sop" | "framework";
32
-
33
- const VALID_TARGETS: ReadonlyArray<TargetKind> = ["conversation", "task", "scheduler_event", "subagent"];
34
- function isValidTargetKind(v: unknown): v is TargetKind {
35
- return typeof v === "string" && (VALID_TARGETS as readonly string[]).includes(v);
36
- }
37
-
38
- // ── Token cost (char/4 heuristic, matches dashboard) ──
39
- function estimateTokens(content: unknown): number {
40
- if (content == null) return 0;
41
- const str = typeof content === "string" ? content : JSON.stringify(content);
42
- return Math.ceil(str.length / 4);
43
- }
44
-
45
- // ── Attachment write logic ──
46
- async function applyAttachmentWrite(args: {
47
- supabase: SupabaseClient;
48
- userId: string;
49
- targetKind: TargetKind;
50
- targetId: string;
51
- docKind: DocKind;
52
- docIds: string[];
53
- }): Promise<{ ok: boolean; error?: string }> {
54
- const { supabase, userId, targetKind, targetId, docKind, docIds } = args;
55
- if (!docIds.length) return { ok: true };
56
-
57
- if (targetKind === "conversation") {
58
- const col = docKind === "sop" ? "attached_sop_ids" : "attached_framework_ids";
59
- const { data: cur, error: readErr } = await supabase
60
- .from("conversations")
61
- .select(col)
62
- .eq("user_id", userId)
63
- .eq("id", targetId)
64
- .maybeSingle();
65
- if (readErr) return { ok: false, error: readErr.message };
66
- const existing = ((cur as Record<string, string[] | undefined> | null)?.[col]) || [];
67
- const merged = Array.from(new Set([...existing, ...docIds]));
68
- const { error: writeErr } = await supabase
69
- .from("conversations")
70
- .update({ [col]: merged, updated_at: new Date().toISOString() })
71
- .eq("user_id", userId)
72
- .eq("id", targetId);
73
- if (writeErr) return { ok: false, error: writeErr.message };
74
- return { ok: true };
75
- }
76
-
77
- if (targetKind === "task" || targetKind === "scheduler_event") {
78
- const tbl = targetKind === "task" ? "tasks" : "scheduler_events";
79
- const fieldKey = docKind === "sop" ? "sop_ids" : "framework_ids";
80
- const { data: cur, error: readErr } = await supabase
81
- .from(tbl)
82
- .select("custom_fields")
83
- .eq("user_id", userId)
84
- .eq("id", targetId)
85
- .maybeSingle();
86
- if (readErr) return { ok: false, error: readErr.message };
87
- const cf = (cur?.custom_fields as Record<string, unknown> | null) || {};
88
- const existing: string[] = Array.isArray(cf?.[fieldKey]) ? (cf[fieldKey] as string[]) : [];
89
- const merged = Array.from(new Set([...existing, ...docIds]));
90
- const nextCf = { ...cf, [fieldKey]: merged };
91
- const { error: writeErr } = await supabase
92
- .from(tbl)
93
- .update({ custom_fields: nextCf, updated_at: new Date().toISOString() })
94
- .eq("user_id", userId)
95
- .eq("id", targetId);
96
- if (writeErr) return { ok: false, error: writeErr.message };
97
- return { ok: true };
98
- }
99
-
100
- // Cycle 11 — subagent target_kind. Writes profile-level defaults that apply
101
- // across EVERY dispatch to the staff (unioned with per-task ids at dispatch
102
- // time in task-dispatcher edge fn via loadStaffDefaultAttachments).
103
- if (targetKind === "subagent") {
104
- const col = docKind === "sop" ? "attached_sop_ids" : "attached_framework_ids";
105
- const { data: cur, error: readErr } = await supabase
106
- .from("agent_subagents")
107
- .select(col)
108
- .eq("user_id", userId)
109
- .eq("id", targetId)
110
- .maybeSingle();
111
- if (readErr) return { ok: false, error: readErr.message };
112
- const existing = ((cur as Record<string, string[] | undefined> | null)?.[col]) || [];
113
- const merged = Array.from(new Set([...existing, ...docIds]));
114
- const { error: writeErr } = await supabase
115
- .from("agent_subagents")
116
- .update({ [col]: merged })
117
- .eq("user_id", userId)
118
- .eq("id", targetId);
119
- if (writeErr) return { ok: false, error: writeErr.message };
120
- return { ok: true };
121
- }
122
-
123
- return { ok: false, error: "unsupported target_kind" };
124
- }
125
-
126
- // ── Load target row identity (chief agent + optional subagent) for tier check ──
127
- interface TargetIdentity {
128
- agentId: string | null;
129
- subagentId: string | null;
130
- }
131
-
132
- async function loadTargetIdentity(args: {
133
- supabase: SupabaseClient;
134
- userId: string;
135
- targetKind: TargetKind;
136
- targetId: string;
137
- }): Promise<TargetIdentity | null> {
138
- const { supabase, userId, targetKind, targetId } = args;
139
- // Cycle 11 — subagent target: identity is { agentId: chief_agent_id, subagentId: row.id }.
140
- // resolveTargetTier sees subagentId set → returns "staff" tier without a chief lookup.
141
- if (targetKind === "subagent") {
142
- const { data } = await supabase
143
- .from("agent_subagents")
144
- .select("id, chief_agent_id")
145
- .eq("user_id", userId)
146
- .eq("id", targetId)
147
- .maybeSingle();
148
- if (!data) return null;
149
- return {
150
- agentId: (data.chief_agent_id as string | null) ?? null,
151
- subagentId: (data.id as string | null) ?? null,
152
- };
153
- }
154
- const tbl =
155
- targetKind === "conversation" ? "conversations" :
156
- targetKind === "task" ? "tasks" : "scheduler_events";
157
- const { data } = await supabase
158
- .from(tbl)
159
- .select("agent_id, subagent_id")
160
- .eq("user_id", userId)
161
- .eq("id", targetId)
162
- .maybeSingle();
163
- if (!data) return null;
164
- return {
165
- agentId: (data.agent_id as string | null) ?? null,
166
- subagentId: (data.subagent_id as string | null) ?? null,
167
- };
168
- }
169
-
170
- // ── Validate that every doc id belongs to userId in the right table ──
171
- async function validateDocIds(args: {
172
- supabase: SupabaseClient;
173
- userId: string;
174
- docKind: DocKind;
175
- docIds: string[];
176
- }): Promise<{ ok: boolean; matched: string[] }> {
177
- const { supabase, userId, docKind, docIds } = args;
178
- if (!docIds.length) return { ok: true, matched: [] };
179
- const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
180
- const { data } = await supabase
181
- .from(tbl)
182
- .select("id")
183
- .eq("user_id", userId)
184
- .in("id", docIds);
185
- const matched = (data || []).map((r: { id: string }) => r.id);
186
- return { ok: matched.length === docIds.length, matched };
187
- }
188
-
189
- // ── propose_attach: validate + compute cost + issue token ──
190
- export async function handleProposeAttach(args: {
191
- supabase: SupabaseClient;
192
- userId: string;
193
- docKind: DocKind;
194
- params: Record<string, unknown>;
195
- }): Promise<ToolResult> {
196
- const { supabase, userId, docKind, params } = args;
197
- if (!isValidTargetKind(params.target_kind)) {
198
- return err("Invalid target_kind. Use one of: conversation, task, scheduler_event");
199
- }
200
- const targetKind = params.target_kind as TargetKind;
201
- const targetId = params.target_id as string;
202
- if (!targetId) return err("Missing target_id");
203
- if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
204
- return err("Missing doc_ids[]");
205
- }
206
- const docIds = (params.doc_ids as unknown[]).filter(
207
- (x): x is string => typeof x === "string",
208
- );
209
-
210
- const identity = await loadTargetIdentity({
211
- supabase, userId, targetKind, targetId,
212
- });
213
- if (!identity || (!identity.agentId && !identity.subagentId)) {
214
- return err("target_not_found_or_no_agent");
215
- }
216
-
217
- const tierResolution = await resolveTargetTier(supabase, identity, userId);
218
- if (!tierResolution.tier) {
219
- return err("agent_unclassified — set tier in dashboard agent settings or via override");
220
- }
221
- if (!isDocKindValidForTier(docKind, tierResolution.tier)) {
222
- return err(
223
- `tier_mismatch — assigned agent is ${tierResolution.tier}; ` +
224
- (docKind === "sop"
225
- ? "SOPs only attach to staff"
226
- : "Frameworks only attach to c-suite"),
227
- );
228
- }
229
-
230
- const v = await validateDocIds({ supabase, userId, docKind, docIds });
231
- if (!v.ok) return err("unknown_doc — one or more doc_ids do not belong to you");
232
-
233
- // Cost summary
234
- const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
235
- const { data: rows } = await supabase
236
- .from(tbl)
237
- .select("id, title, content")
238
- .eq("user_id", userId)
239
- .in("id", docIds);
240
- const breakdown = (rows || []).map((r: { id: string; title: string; content: string }) => ({
241
- id: r.id,
242
- title: r.title || (docKind === "sop" ? "Untitled SOP" : "Untitled Framework"),
243
- tokens: estimateTokens(r.content),
244
- kind: docKind,
245
- }));
246
- const totalTokens = breakdown.reduce((acc, b) => acc + b.tokens, 0);
247
-
248
- let token: string;
249
- try {
250
- token = issueAttachmentToken({
251
- userId, targetKind, targetId, docKind, docIds,
252
- });
253
- } catch (e) {
254
- return err(`token_issue_failed: ${e instanceof Error ? e.message : String(e)}`);
255
- }
256
-
257
- const noun = docKind === "sop" ? "SOP" : "Framework";
258
- const plural = docIds.length === 1 ? noun : `${noun}s`;
259
- const summary = `Adding ${docIds.length} ${plural} (~${totalTokens.toLocaleString()} tokens) to ${targetKind} ${targetId}.`;
260
-
261
- return ok({
262
- requires_confirmation: true,
263
- target_kind: targetKind,
264
- target_id: targetId,
265
- doc_kind: docKind,
266
- doc_ids: docIds,
267
- tier: tierResolution.tier,
268
- tier_source: tierResolution.source,
269
- token_cost: totalTokens,
270
- breakdown,
271
- confirmation_token: token,
272
- confirmation_prompt:
273
- `${summary} Ask the user: "Proceed? (~${totalTokens.toLocaleString()} tokens)" — only call commit_attach AFTER user approves.`,
274
- });
275
- }
276
-
277
- // ── commit_attach: verify token + write ──
278
- export async function handleCommitAttach(args: {
279
- supabase: SupabaseClient;
280
- userId: string;
281
- docKind: DocKind;
282
- params: Record<string, unknown>;
283
- }): Promise<ToolResult> {
284
- const { supabase, userId, docKind, params } = args;
285
- if (!isValidTargetKind(params.target_kind)) return err("Invalid target_kind");
286
- const targetKind = params.target_kind as TargetKind;
287
- const targetId = params.target_id as string;
288
- if (!targetId) return err("Missing target_id");
289
- if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
290
- return err("Missing doc_ids[]");
291
- }
292
- const docIds = (params.doc_ids as unknown[]).filter(
293
- (x): x is string => typeof x === "string",
294
- );
295
- const token = params.confirmation_token as string;
296
- if (!token) return err("Missing confirmation_token (call propose_attach first)");
297
-
298
- const v = verifyAttachmentToken({
299
- token, userId, targetKind, targetId, docKind, docIds,
300
- });
301
- if (!v.ok) return err(`token_${v.reason}`);
302
-
303
- const wr = await applyAttachmentWrite({
304
- supabase, userId, targetKind, targetId, docKind, docIds,
305
- });
306
- if (!wr.ok) return err(wr.error || "write_failed");
307
-
308
- // Invalidate the per-agent attachment cache so the next prompt build picks up the new ids
309
- attachmentCache.clear();
310
-
311
- return ok({
312
- ok: true,
313
- attached: docIds.length,
314
- target_kind: targetKind,
315
- target_id: targetId,
316
- doc_kind: docKind,
317
- });
318
- }
319
-
320
- // ── before_prompt_build hook ──
321
- const attachmentCache = new Map<string, { text: string; at: number }>();
322
- const ATTACH_CACHE_TTL_MS = 600_000;
323
- const ATTACH_CACHE_STALE_MS = 900_000;
324
-
325
- function isSystemName(name: string): boolean {
326
- const s = name.toLowerCase().trim();
327
- return s === "" || s === "ofiere" || s === "openclaw" || s === "system" || s === "plugin" || s === "gateway" || s.includes("plugin");
328
- }
329
-
330
- // Render a block from explicit doc-id lists. Used by both the dispatch-params
331
- // path (when workflow/task-dispatcher hands ids in via ctx) and the conversation
332
- // fallback path (when no explicit ids are present).
333
- async function renderBlockForIds(args: {
334
- supabase: SupabaseClient;
335
- userId: string;
336
- sopIds: string[];
337
- fwIds: string[];
338
- }): Promise<string> {
339
- const { supabase, userId, sopIds, fwIds } = args;
340
- if (!sopIds.length && !fwIds.length) return "";
341
-
342
- const [sopsRes, fwsRes] = await Promise.all([
343
- sopIds.length
344
- ? supabase
345
- .from("agent_sops")
346
- .select("title, content")
347
- .eq("user_id", userId)
348
- .in("id", sopIds)
349
- : Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
350
- fwIds.length
351
- ? supabase
352
- .from("frameworks")
353
- .select("title, content")
354
- .eq("user_id", userId)
355
- .in("id", fwIds)
356
- : Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
357
- ]);
358
-
359
- const sops = (sopsRes.data || []).map((s) => ({
360
- title: s.title || "Untitled SOP",
361
- content: typeof s.content === "string" ? s.content : JSON.stringify(s.content ?? ""),
362
- }));
363
- const frameworks = (fwsRes.data || []).map((f) => ({
364
- title: f.title || "Untitled Framework",
365
- content: typeof f.content === "string" ? f.content : JSON.stringify(f.content ?? ""),
366
- }));
367
-
368
- return renderAttachmentBlock({ sops, frameworks });
369
- }
370
-
371
- // Pluck attachment ids from the `before_prompt_build` ctx. Exact path varies by
372
- // the dispatch surface that fires the prompt build, so we probe several known
373
- // locations. Workflow executor + task-dispatcher edge function both stash ids
374
- // under `metadata` on their request frames.
375
- function readDispatchAttachmentIds(ctx: any): { sopIds: string[]; fwIds: string[] } {
376
- const candidates: Array<Record<string, unknown> | undefined> = [
377
- ctx?.metadata,
378
- ctx?.params?.metadata,
379
- ctx?.payload?.metadata,
380
- ctx?.request?.metadata,
381
- ctx?.options?.metadata,
382
- ctx, // last resort: top level
383
- ];
384
- for (const c of candidates) {
385
- if (!c || typeof c !== "object") continue;
386
- const sop = (c as any).attached_sop_ids;
387
- const fw = (c as any).attached_framework_ids;
388
- if (Array.isArray(sop) || Array.isArray(fw)) {
389
- return {
390
- sopIds: Array.isArray(sop) ? sop.filter((x: unknown): x is string => typeof x === "string") : [],
391
- fwIds: Array.isArray(fw) ? fw.filter((x: unknown): x is string => typeof x === "string") : [],
392
- };
393
- }
394
- }
395
- return { sopIds: [], fwIds: [] };
396
- }
397
-
398
- async function buildAttachmentBlock(args: {
399
- supabase: SupabaseClient;
400
- userId: string;
401
- agentId: string;
402
- subagentId?: string | null;
403
- }): Promise<string> {
404
- const { supabase, userId, agentId, subagentId } = args;
405
-
406
- // When a staff dispatch arrives, prefer attachments scoped to the subagent
407
- // so a chief and its staff never share an attachment surface implicitly.
408
- if (subagentId) {
409
- const { data: staffConv } = await supabase
410
- .from("conversations")
411
- .select("id, attached_sop_ids, attached_framework_ids")
412
- .eq("user_id", userId)
413
- .eq("agent_id", agentId)
414
- .eq("subagent_id", subagentId)
415
- .order("updated_at", { ascending: false })
416
- .limit(1)
417
- .maybeSingle();
418
- if (staffConv) {
419
- const sopIds: string[] = (staffConv.attached_sop_ids as string[] | null) || [];
420
- const fwIds: string[] = (staffConv.attached_framework_ids as string[] | null) || [];
421
- return renderBlockForIds({ supabase, userId, sopIds, fwIds });
422
- }
423
- // No staff-scoped conversation yet — fall through to chief-level lookup.
424
- }
425
-
426
- // Find the most recently active conversation for this agent. The dashboard
427
- // bumps `updated_at` whenever a message is sent or attachments change, so
428
- // this gives us the right run target most of the time.
429
- const { data: convRow } = await supabase
430
- .from("conversations")
431
- .select("id, attached_sop_ids, attached_framework_ids")
432
- .eq("user_id", userId)
433
- .eq("agent_id", agentId)
434
- .order("updated_at", { ascending: false })
435
- .limit(1)
436
- .maybeSingle();
437
-
438
- const sopIds: string[] = (convRow?.attached_sop_ids as string[] | null) || [];
439
- const fwIds: string[] = (convRow?.attached_framework_ids as string[] | null) || [];
440
- return renderBlockForIds({ supabase, userId, sopIds, fwIds });
441
- }
442
-
443
- export function registerAttachmentContextHook(args: {
444
- api: any;
445
- supabase: SupabaseClient;
446
- userId: string;
447
- fallbackAgentId: string;
448
- resolveAgent: (id?: string) => Promise<string | null>;
449
- }): void {
450
- const { api, supabase, userId, fallbackAgentId, resolveAgent } = args;
451
-
452
- try {
453
- api.on("before_prompt_build", async (_event: any, ctx: any) => {
454
- try {
455
- const ctxAgentId = ctx?.agentId || "";
456
- let resolvedAgentId = fallbackAgentId;
457
- if (ctxAgentId && !isSystemName(ctxAgentId)) {
458
- try {
459
- const r = await resolveAgent(ctxAgentId);
460
- if (r) resolvedAgentId = r;
461
- } catch { /* fall through */ }
462
- }
463
- if (!resolvedAgentId) return;
464
-
465
- // Cycle 7b — staff persona injection. Only fires when subagent_id is
466
- // present in dispatch metadata (task-dispatcher / scheduler / explicit
467
- // dispatch params). Plain user chats with the chief never persona-swap.
468
- //
469
- // Cycle 7b BUGSHOOT-1 (BUG 2) — OpenClaw gateway core rejects unknown
470
- // root key `metadata` on chat.send, so the dispatcher can't ride
471
- // metadata through the wire. After the metadata path comes up empty,
472
- // try the dispatch_context table keyed by sessionKey. The metadata
473
- // path is preferred so a future gateway upgrade keeps working.
474
- const sessionKey: string | null =
475
- ctx?.sessionKey || ctx?.session_key ||
476
- (typeof _event === "object" ? (_event?.sessionKey || _event?.context?.sessionKey || null) : null) ||
477
- null;
478
- let dispatchCtxRow: DispatchContextRow | null = null;
479
- let subagentId = readDispatchSubagentId(ctx);
480
- if (!subagentId && sessionKey) {
481
- dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
482
- if (dispatchCtxRow?.subagent_id) {
483
- subagentId = dispatchCtxRow.subagent_id;
484
- api.logger?.debug?.(
485
- `[ofiere-staff] subagent_id resolved via dispatch_context (session=${sessionKey})`,
486
- );
487
- }
488
- }
489
- let staffPrefix = "";
490
- let staffRow: SubagentRow | null = null;
491
- if (subagentId) {
492
- staffRow = await loadSubagentRow(supabase, userId, subagentId);
493
- if (staffRow && staffRow.chief_agent_id === resolvedAgentId) {
494
- const [chiefDefaults, chiefConfig] = await Promise.all([
495
- loadChiefStaffDefaults(supabase, userId, staffRow.chief_agent_id).catch(() => null),
496
- loadChiefAgentConfig(supabase, userId, staffRow.chief_agent_id).catch(() => null),
497
- ]);
498
- const resolved = resolveStaffModels(staffRow, chiefDefaults, chiefConfig);
499
- staffPrefix = buildStaffPersonaBlock(staffRow, resolved) + "\n\n---\n\n";
500
- api.logger?.debug?.(
501
- `[ofiere-staff] subagent ${subagentId} (${staffRow.name}) reporting to ${resolvedAgentId} — persona injected; resolved models: ${JSON.stringify(resolved)}`,
502
- );
503
- } else if (staffRow) {
504
- api.logger?.warn?.(
505
- `[ofiere-staff] subagent ${subagentId} chief_mismatch (got ${staffRow.chief_agent_id}, expected ${resolvedAgentId}) — ignoring persona`,
506
- );
507
- staffRow = null;
508
- } else {
509
- api.logger?.warn?.(`[ofiere-staff] subagent ${subagentId} not found — ignoring persona`);
510
- }
511
- }
512
-
513
- // wrap any return-block with the staff prefix (or pass through unchanged
514
- // when no staff dispatch).
515
- const wrap = (block: string | undefined | null): { appendSystemContext: string } | undefined => {
516
- const finalBlock = staffPrefix + (block || "");
517
- return finalBlock ? { appendSystemContext: finalBlock } : undefined;
518
- };
519
-
520
- // Dispatch-params path: workflow executor + task-dispatcher edge function
521
- // can stash explicit `attached_sop_ids` / `attached_framework_ids` on the
522
- // chat.send frame's metadata. When present, prefer them over the
523
- // most-recent-conversation lookup and bypass cache (per-dispatch ids).
524
- let dispatchIds = readDispatchAttachmentIds(ctx);
525
- // Cycle 7b BUGSHOOT-1 (BUG 2) — DB-backed dispatch-context fallback for
526
- // attachment ids. Same rationale as the subagent_id fallback above.
527
- if (!dispatchIds.sopIds.length && !dispatchIds.fwIds.length && sessionKey) {
528
- if (!dispatchCtxRow) {
529
- dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
530
- }
531
- if (dispatchCtxRow && (dispatchCtxRow.attached_sop_ids.length || dispatchCtxRow.attached_framework_ids.length)) {
532
- dispatchIds = {
533
- sopIds: dispatchCtxRow.attached_sop_ids,
534
- fwIds: dispatchCtxRow.attached_framework_ids,
535
- };
536
- api.logger?.debug?.(
537
- `[ofiere-attach] dispatch ids via dispatch_context (session=${sessionKey}) sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
538
- );
539
- }
540
- }
541
- if (dispatchIds.sopIds.length || dispatchIds.fwIds.length) {
542
- api.logger?.debug?.(
543
- `[ofiere-attach] dispatch ids agent=${resolvedAgentId} sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
544
- );
545
- const block = await renderBlockForIds({
546
- supabase, userId,
547
- sopIds: dispatchIds.sopIds,
548
- fwIds: dispatchIds.fwIds,
549
- });
550
- return wrap(block);
551
- }
552
-
553
- // Staff-mode runs bypass the chief-level attachment cache so a chief
554
- // and its staff never share a cached attachment block.
555
- if (subagentId) {
556
- const block = await buildAttachmentBlock({
557
- supabase, userId, agentId: resolvedAgentId, subagentId,
558
- });
559
- return wrap(block);
560
- }
561
-
562
- // Multi-tenant: a single plugin process can serve multiple users — key
563
- // by (userId, agentId) so User B never sees User A's cached block.
564
- const cacheKey = `${userId}::${resolvedAgentId}`;
565
- const cached = attachmentCache.get(cacheKey);
566
- if (cached) {
567
- const age = Date.now() - cached.at;
568
- if (age < ATTACH_CACHE_TTL_MS) {
569
- return wrap(cached.text);
570
- }
571
- if (age < ATTACH_CACHE_STALE_MS) {
572
- // Refresh in background
573
- (async () => {
574
- try {
575
- const fresh = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
576
- attachmentCache.set(cacheKey, { text: fresh, at: Date.now() });
577
- } catch { /* ignore */ }
578
- })();
579
- return wrap(cached.text);
580
- }
581
- }
582
-
583
- const block = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
584
- attachmentCache.set(cacheKey, { text: block, at: Date.now() });
585
- return wrap(block);
586
- } catch (e) {
587
- api.logger?.debug?.(`[ofiere-attach] before_prompt_build error: ${e instanceof Error ? e.message : e}`);
588
- }
589
- });
590
- } catch {
591
- api.logger?.debug?.("[ofiere] Could not register attachment context hook — appendSystemContext may not be supported");
592
- }
593
- }
594
-
595
- // Public for tests / future hooks that need to drop the cache
596
- export function invalidateAttachmentCache(): void {
597
- attachmentCache.clear();
598
- }
599
-
600
- // Re-export tier invalidation so callers don't need to import two modules
601
- export { invalidateAgentTier };
1
+ // src/attachments.ts — Plugin-side SOP/Framework attachment plumbing.
2
+ // Three responsibilities:
3
+ // 1. before_prompt_build hook: read the most-recent conversation for the
4
+ // caller's agent and inject any attached SOPs/Frameworks as system
5
+ // context (cached per-agent like the brain hook).
6
+ // 2. propose_attach: validate target tier + doc kind, return token-cost
7
+ // summary + HMAC confirmation token (5 min ttl).
8
+ // 3. commit_attach: re-verify token, write attachment ids to the row.
9
+
10
+ import type { SupabaseClient } from "@supabase/supabase-js";
11
+ import { renderAttachmentBlock } from "./sop-render.js";
12
+ import {
13
+ resolveTargetTier,
14
+ isDocKindValidForTier,
15
+ invalidateAgentTier,
16
+ } from "./agent-tier.js";
17
+ import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
18
+ import { loadSubagentRow, buildStaffPersonaBlock, readDispatchSubagentId, loadChiefStaffDefaults, loadChiefAgentConfig, loadDispatchContextBySession, resolveStaffModels, type SubagentRow, type DispatchContextRow } from "./staffPersona.js";
19
+
20
+ interface ToolResult {
21
+ content: Array<{ type: "text"; text: string }>;
22
+ }
23
+ function ok(data: unknown): ToolResult {
24
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
25
+ }
26
+ function err(message: string): ToolResult {
27
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }] };
28
+ }
29
+
30
+ export type TargetKind = "conversation" | "task" | "scheduler_event" | "subagent";
31
+ export type DocKind = "sop" | "framework";
32
+
33
+ const VALID_TARGETS: ReadonlyArray<TargetKind> = ["conversation", "task", "scheduler_event", "subagent"];
34
+ function isValidTargetKind(v: unknown): v is TargetKind {
35
+ return typeof v === "string" && (VALID_TARGETS as readonly string[]).includes(v);
36
+ }
37
+
38
+ // ── Token cost (char/4 heuristic, matches dashboard) ──
39
+ function estimateTokens(content: unknown): number {
40
+ if (content == null) return 0;
41
+ const str = typeof content === "string" ? content : JSON.stringify(content);
42
+ return Math.ceil(str.length / 4);
43
+ }
44
+
45
+ // ── Attachment write logic ──
46
+ async function applyAttachmentWrite(args: {
47
+ supabase: SupabaseClient;
48
+ userId: string;
49
+ targetKind: TargetKind;
50
+ targetId: string;
51
+ docKind: DocKind;
52
+ docIds: string[];
53
+ }): Promise<{ ok: boolean; error?: string }> {
54
+ const { supabase, userId, targetKind, targetId, docKind, docIds } = args;
55
+ if (!docIds.length) return { ok: true };
56
+
57
+ if (targetKind === "conversation") {
58
+ const col = docKind === "sop" ? "attached_sop_ids" : "attached_framework_ids";
59
+ const { data: cur, error: readErr } = await supabase
60
+ .from("conversations")
61
+ .select(col)
62
+ .eq("user_id", userId)
63
+ .eq("id", targetId)
64
+ .maybeSingle();
65
+ if (readErr) return { ok: false, error: readErr.message };
66
+ const existing = ((cur as Record<string, string[] | undefined> | null)?.[col]) || [];
67
+ const merged = Array.from(new Set([...existing, ...docIds]));
68
+ const { error: writeErr } = await supabase
69
+ .from("conversations")
70
+ .update({ [col]: merged, updated_at: new Date().toISOString() })
71
+ .eq("user_id", userId)
72
+ .eq("id", targetId);
73
+ if (writeErr) return { ok: false, error: writeErr.message };
74
+ return { ok: true };
75
+ }
76
+
77
+ if (targetKind === "task" || targetKind === "scheduler_event") {
78
+ const tbl = targetKind === "task" ? "tasks" : "scheduler_events";
79
+ const fieldKey = docKind === "sop" ? "sop_ids" : "framework_ids";
80
+ const { data: cur, error: readErr } = await supabase
81
+ .from(tbl)
82
+ .select("custom_fields")
83
+ .eq("user_id", userId)
84
+ .eq("id", targetId)
85
+ .maybeSingle();
86
+ if (readErr) return { ok: false, error: readErr.message };
87
+ const cf = (cur?.custom_fields as Record<string, unknown> | null) || {};
88
+ const existing: string[] = Array.isArray(cf?.[fieldKey]) ? (cf[fieldKey] as string[]) : [];
89
+ const merged = Array.from(new Set([...existing, ...docIds]));
90
+ const nextCf = { ...cf, [fieldKey]: merged };
91
+ const { error: writeErr } = await supabase
92
+ .from(tbl)
93
+ .update({ custom_fields: nextCf, updated_at: new Date().toISOString() })
94
+ .eq("user_id", userId)
95
+ .eq("id", targetId);
96
+ if (writeErr) return { ok: false, error: writeErr.message };
97
+ return { ok: true };
98
+ }
99
+
100
+ // Cycle 11 — subagent target_kind. Writes profile-level defaults that apply
101
+ // across EVERY dispatch to the staff (unioned with per-task ids at dispatch
102
+ // time in task-dispatcher edge fn via loadStaffDefaultAttachments).
103
+ if (targetKind === "subagent") {
104
+ const col = docKind === "sop" ? "attached_sop_ids" : "attached_framework_ids";
105
+ const { data: cur, error: readErr } = await supabase
106
+ .from("agent_subagents")
107
+ .select(col)
108
+ .eq("user_id", userId)
109
+ .eq("id", targetId)
110
+ .maybeSingle();
111
+ if (readErr) return { ok: false, error: readErr.message };
112
+ const existing = ((cur as Record<string, string[] | undefined> | null)?.[col]) || [];
113
+ const merged = Array.from(new Set([...existing, ...docIds]));
114
+ const { error: writeErr } = await supabase
115
+ .from("agent_subagents")
116
+ .update({ [col]: merged })
117
+ .eq("user_id", userId)
118
+ .eq("id", targetId);
119
+ if (writeErr) return { ok: false, error: writeErr.message };
120
+ return { ok: true };
121
+ }
122
+
123
+ return { ok: false, error: "unsupported target_kind" };
124
+ }
125
+
126
+ // ── Load target row identity (chief agent + optional subagent) for tier check ──
127
+ interface TargetIdentity {
128
+ agentId: string | null;
129
+ subagentId: string | null;
130
+ }
131
+
132
+ async function loadTargetIdentity(args: {
133
+ supabase: SupabaseClient;
134
+ userId: string;
135
+ targetKind: TargetKind;
136
+ targetId: string;
137
+ }): Promise<TargetIdentity | null> {
138
+ const { supabase, userId, targetKind, targetId } = args;
139
+ // Cycle 11 — subagent target: identity is { agentId: chief_agent_id, subagentId: row.id }.
140
+ // resolveTargetTier sees subagentId set → returns "staff" tier without a chief lookup.
141
+ if (targetKind === "subagent") {
142
+ const { data } = await supabase
143
+ .from("agent_subagents")
144
+ .select("id, chief_agent_id")
145
+ .eq("user_id", userId)
146
+ .eq("id", targetId)
147
+ .maybeSingle();
148
+ if (!data) return null;
149
+ return {
150
+ agentId: (data.chief_agent_id as string | null) ?? null,
151
+ subagentId: (data.id as string | null) ?? null,
152
+ };
153
+ }
154
+ const tbl =
155
+ targetKind === "conversation" ? "conversations" :
156
+ targetKind === "task" ? "tasks" : "scheduler_events";
157
+ const { data } = await supabase
158
+ .from(tbl)
159
+ .select("agent_id, subagent_id")
160
+ .eq("user_id", userId)
161
+ .eq("id", targetId)
162
+ .maybeSingle();
163
+ if (!data) return null;
164
+ return {
165
+ agentId: (data.agent_id as string | null) ?? null,
166
+ subagentId: (data.subagent_id as string | null) ?? null,
167
+ };
168
+ }
169
+
170
+ // ── Validate that every doc id belongs to userId in the right table ──
171
+ async function validateDocIds(args: {
172
+ supabase: SupabaseClient;
173
+ userId: string;
174
+ docKind: DocKind;
175
+ docIds: string[];
176
+ }): Promise<{ ok: boolean; matched: string[] }> {
177
+ const { supabase, userId, docKind, docIds } = args;
178
+ if (!docIds.length) return { ok: true, matched: [] };
179
+ const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
180
+ const { data } = await supabase
181
+ .from(tbl)
182
+ .select("id")
183
+ .eq("user_id", userId)
184
+ .in("id", docIds);
185
+ const matched = (data || []).map((r: { id: string }) => r.id);
186
+ return { ok: matched.length === docIds.length, matched };
187
+ }
188
+
189
+ // ── propose_attach: validate + compute cost + issue token ──
190
+ export async function handleProposeAttach(args: {
191
+ supabase: SupabaseClient;
192
+ userId: string;
193
+ docKind: DocKind;
194
+ params: Record<string, unknown>;
195
+ }): Promise<ToolResult> {
196
+ const { supabase, userId, docKind, params } = args;
197
+ if (!isValidTargetKind(params.target_kind)) {
198
+ return err("Invalid target_kind. Use one of: conversation, task, scheduler_event");
199
+ }
200
+ const targetKind = params.target_kind as TargetKind;
201
+ const targetId = params.target_id as string;
202
+ if (!targetId) return err("Missing target_id");
203
+ if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
204
+ return err("Missing doc_ids[]");
205
+ }
206
+ const docIds = (params.doc_ids as unknown[]).filter(
207
+ (x): x is string => typeof x === "string",
208
+ );
209
+
210
+ const identity = await loadTargetIdentity({
211
+ supabase, userId, targetKind, targetId,
212
+ });
213
+ if (!identity || (!identity.agentId && !identity.subagentId)) {
214
+ return err("target_not_found_or_no_agent");
215
+ }
216
+
217
+ const tierResolution = await resolveTargetTier(supabase, identity, userId);
218
+ if (!tierResolution.tier) {
219
+ return err("agent_unclassified — set tier in dashboard agent settings or via override");
220
+ }
221
+ if (!isDocKindValidForTier(docKind, tierResolution.tier)) {
222
+ return err(
223
+ `tier_mismatch — assigned agent is ${tierResolution.tier}; ` +
224
+ (docKind === "sop"
225
+ ? "SOPs only attach to staff"
226
+ : "Frameworks only attach to c-suite"),
227
+ );
228
+ }
229
+
230
+ const v = await validateDocIds({ supabase, userId, docKind, docIds });
231
+ if (!v.ok) return err("unknown_doc — one or more doc_ids do not belong to you");
232
+
233
+ // Cost summary
234
+ const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
235
+ const { data: rows } = await supabase
236
+ .from(tbl)
237
+ .select("id, title, content")
238
+ .eq("user_id", userId)
239
+ .in("id", docIds);
240
+ const breakdown = (rows || []).map((r: { id: string; title: string; content: string }) => ({
241
+ id: r.id,
242
+ title: r.title || (docKind === "sop" ? "Untitled SOP" : "Untitled Framework"),
243
+ tokens: estimateTokens(r.content),
244
+ kind: docKind,
245
+ }));
246
+ const totalTokens = breakdown.reduce((acc, b) => acc + b.tokens, 0);
247
+
248
+ let token: string;
249
+ try {
250
+ token = issueAttachmentToken({
251
+ userId, targetKind, targetId, docKind, docIds,
252
+ });
253
+ } catch (e) {
254
+ return err(`token_issue_failed: ${e instanceof Error ? e.message : String(e)}`);
255
+ }
256
+
257
+ const noun = docKind === "sop" ? "SOP" : "Framework";
258
+ const plural = docIds.length === 1 ? noun : `${noun}s`;
259
+ const summary = `Adding ${docIds.length} ${plural} (~${totalTokens.toLocaleString()} tokens) to ${targetKind} ${targetId}.`;
260
+
261
+ return ok({
262
+ requires_confirmation: true,
263
+ target_kind: targetKind,
264
+ target_id: targetId,
265
+ doc_kind: docKind,
266
+ doc_ids: docIds,
267
+ tier: tierResolution.tier,
268
+ tier_source: tierResolution.source,
269
+ token_cost: totalTokens,
270
+ breakdown,
271
+ confirmation_token: token,
272
+ confirmation_prompt:
273
+ `${summary} Ask the user: "Proceed? (~${totalTokens.toLocaleString()} tokens)" — only call commit_attach AFTER user approves.`,
274
+ });
275
+ }
276
+
277
+ // ── commit_attach: verify token + write ──
278
+ export async function handleCommitAttach(args: {
279
+ supabase: SupabaseClient;
280
+ userId: string;
281
+ docKind: DocKind;
282
+ params: Record<string, unknown>;
283
+ }): Promise<ToolResult> {
284
+ const { supabase, userId, docKind, params } = args;
285
+ if (!isValidTargetKind(params.target_kind)) return err("Invalid target_kind");
286
+ const targetKind = params.target_kind as TargetKind;
287
+ const targetId = params.target_id as string;
288
+ if (!targetId) return err("Missing target_id");
289
+ if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
290
+ return err("Missing doc_ids[]");
291
+ }
292
+ const docIds = (params.doc_ids as unknown[]).filter(
293
+ (x): x is string => typeof x === "string",
294
+ );
295
+ const token = params.confirmation_token as string;
296
+ if (!token) return err("Missing confirmation_token (call propose_attach first)");
297
+
298
+ const v = verifyAttachmentToken({
299
+ token, userId, targetKind, targetId, docKind, docIds,
300
+ });
301
+ if (!v.ok) return err(`token_${v.reason}`);
302
+
303
+ const wr = await applyAttachmentWrite({
304
+ supabase, userId, targetKind, targetId, docKind, docIds,
305
+ });
306
+ if (!wr.ok) return err(wr.error || "write_failed");
307
+
308
+ // Invalidate the per-agent attachment cache so the next prompt build picks up the new ids
309
+ attachmentCache.clear();
310
+
311
+ return ok({
312
+ ok: true,
313
+ attached: docIds.length,
314
+ target_kind: targetKind,
315
+ target_id: targetId,
316
+ doc_kind: docKind,
317
+ });
318
+ }
319
+
320
+ // ── before_prompt_build hook ──
321
+ const attachmentCache = new Map<string, { text: string; at: number }>();
322
+ const ATTACH_CACHE_TTL_MS = 600_000;
323
+ const ATTACH_CACHE_STALE_MS = 900_000;
324
+
325
+ function isSystemName(name: string): boolean {
326
+ const s = name.toLowerCase().trim();
327
+ return s === "" || s === "ofiere" || s === "openclaw" || s === "system" || s === "plugin" || s === "gateway" || s.includes("plugin");
328
+ }
329
+
330
+ // Render a block from explicit doc-id lists. Used by both the dispatch-params
331
+ // path (when workflow/task-dispatcher hands ids in via ctx) and the conversation
332
+ // fallback path (when no explicit ids are present).
333
+ async function renderBlockForIds(args: {
334
+ supabase: SupabaseClient;
335
+ userId: string;
336
+ sopIds: string[];
337
+ fwIds: string[];
338
+ }): Promise<string> {
339
+ const { supabase, userId, sopIds, fwIds } = args;
340
+ if (!sopIds.length && !fwIds.length) return "";
341
+
342
+ const [sopsRes, fwsRes] = await Promise.all([
343
+ sopIds.length
344
+ ? supabase
345
+ .from("agent_sops")
346
+ .select("title, content")
347
+ .eq("user_id", userId)
348
+ .in("id", sopIds)
349
+ : Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
350
+ fwIds.length
351
+ ? supabase
352
+ .from("frameworks")
353
+ .select("title, content")
354
+ .eq("user_id", userId)
355
+ .in("id", fwIds)
356
+ : Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
357
+ ]);
358
+
359
+ const sops = (sopsRes.data || []).map((s) => ({
360
+ title: s.title || "Untitled SOP",
361
+ content: typeof s.content === "string" ? s.content : JSON.stringify(s.content ?? ""),
362
+ }));
363
+ const frameworks = (fwsRes.data || []).map((f) => ({
364
+ title: f.title || "Untitled Framework",
365
+ content: typeof f.content === "string" ? f.content : JSON.stringify(f.content ?? ""),
366
+ }));
367
+
368
+ return renderAttachmentBlock({ sops, frameworks });
369
+ }
370
+
371
+ // Pluck attachment ids from the `before_prompt_build` ctx. Exact path varies by
372
+ // the dispatch surface that fires the prompt build, so we probe several known
373
+ // locations. Workflow executor + task-dispatcher edge function both stash ids
374
+ // under `metadata` on their request frames.
375
+ function readDispatchAttachmentIds(ctx: any): { sopIds: string[]; fwIds: string[] } {
376
+ const candidates: Array<Record<string, unknown> | undefined> = [
377
+ ctx?.metadata,
378
+ ctx?.params?.metadata,
379
+ ctx?.payload?.metadata,
380
+ ctx?.request?.metadata,
381
+ ctx?.options?.metadata,
382
+ ctx, // last resort: top level
383
+ ];
384
+ for (const c of candidates) {
385
+ if (!c || typeof c !== "object") continue;
386
+ const sop = (c as any).attached_sop_ids;
387
+ const fw = (c as any).attached_framework_ids;
388
+ if (Array.isArray(sop) || Array.isArray(fw)) {
389
+ return {
390
+ sopIds: Array.isArray(sop) ? sop.filter((x: unknown): x is string => typeof x === "string") : [],
391
+ fwIds: Array.isArray(fw) ? fw.filter((x: unknown): x is string => typeof x === "string") : [],
392
+ };
393
+ }
394
+ }
395
+ return { sopIds: [], fwIds: [] };
396
+ }
397
+
398
+ async function buildAttachmentBlock(args: {
399
+ supabase: SupabaseClient;
400
+ userId: string;
401
+ agentId: string;
402
+ subagentId?: string | null;
403
+ }): Promise<string> {
404
+ const { supabase, userId, agentId, subagentId } = args;
405
+
406
+ // When a staff dispatch arrives, prefer attachments scoped to the subagent
407
+ // so a chief and its staff never share an attachment surface implicitly.
408
+ if (subagentId) {
409
+ const { data: staffConv } = await supabase
410
+ .from("conversations")
411
+ .select("id, attached_sop_ids, attached_framework_ids")
412
+ .eq("user_id", userId)
413
+ .eq("agent_id", agentId)
414
+ .eq("subagent_id", subagentId)
415
+ .order("updated_at", { ascending: false })
416
+ .limit(1)
417
+ .maybeSingle();
418
+ if (staffConv) {
419
+ const sopIds: string[] = (staffConv.attached_sop_ids as string[] | null) || [];
420
+ const fwIds: string[] = (staffConv.attached_framework_ids as string[] | null) || [];
421
+ return renderBlockForIds({ supabase, userId, sopIds, fwIds });
422
+ }
423
+ // No staff-scoped conversation yet — fall through to chief-level lookup.
424
+ }
425
+
426
+ // Find the most recently active conversation for this agent. The dashboard
427
+ // bumps `updated_at` whenever a message is sent or attachments change, so
428
+ // this gives us the right run target most of the time.
429
+ const { data: convRow } = await supabase
430
+ .from("conversations")
431
+ .select("id, attached_sop_ids, attached_framework_ids")
432
+ .eq("user_id", userId)
433
+ .eq("agent_id", agentId)
434
+ .order("updated_at", { ascending: false })
435
+ .limit(1)
436
+ .maybeSingle();
437
+
438
+ const sopIds: string[] = (convRow?.attached_sop_ids as string[] | null) || [];
439
+ const fwIds: string[] = (convRow?.attached_framework_ids as string[] | null) || [];
440
+ return renderBlockForIds({ supabase, userId, sopIds, fwIds });
441
+ }
442
+
443
+ export function registerAttachmentContextHook(args: {
444
+ api: any;
445
+ supabase: SupabaseClient;
446
+ userId: string;
447
+ fallbackAgentId: string;
448
+ resolveAgent: (id?: string) => Promise<string | null>;
449
+ }): void {
450
+ const { api, supabase, userId, fallbackAgentId, resolveAgent } = args;
451
+
452
+ try {
453
+ api.on("before_prompt_build", async (_event: any, ctx: any) => {
454
+ try {
455
+ const ctxAgentId = ctx?.agentId || "";
456
+ let resolvedAgentId = fallbackAgentId;
457
+ if (ctxAgentId && !isSystemName(ctxAgentId)) {
458
+ try {
459
+ const r = await resolveAgent(ctxAgentId);
460
+ if (r) resolvedAgentId = r;
461
+ } catch { /* fall through */ }
462
+ }
463
+ if (!resolvedAgentId) return;
464
+
465
+ // Cycle 7b — staff persona injection. Only fires when subagent_id is
466
+ // present in dispatch metadata (task-dispatcher / scheduler / explicit
467
+ // dispatch params). Plain user chats with the chief never persona-swap.
468
+ //
469
+ // Cycle 7b BUGSHOOT-1 (BUG 2) — OpenClaw gateway core rejects unknown
470
+ // root key `metadata` on chat.send, so the dispatcher can't ride
471
+ // metadata through the wire. After the metadata path comes up empty,
472
+ // try the dispatch_context table keyed by sessionKey. The metadata
473
+ // path is preferred so a future gateway upgrade keeps working.
474
+ const sessionKey: string | null =
475
+ ctx?.sessionKey || ctx?.session_key ||
476
+ (typeof _event === "object" ? (_event?.sessionKey || _event?.context?.sessionKey || null) : null) ||
477
+ null;
478
+ let dispatchCtxRow: DispatchContextRow | null = null;
479
+ let subagentId = readDispatchSubagentId(ctx);
480
+ if (!subagentId && sessionKey) {
481
+ dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
482
+ if (dispatchCtxRow?.subagent_id) {
483
+ subagentId = dispatchCtxRow.subagent_id;
484
+ api.logger?.debug?.(
485
+ `[ofiere-staff] subagent_id resolved via dispatch_context (session=${sessionKey})`,
486
+ );
487
+ }
488
+ }
489
+ let staffPrefix = "";
490
+ let staffRow: SubagentRow | null = null;
491
+ if (subagentId) {
492
+ staffRow = await loadSubagentRow(supabase, userId, subagentId);
493
+ if (staffRow && staffRow.chief_agent_id === resolvedAgentId) {
494
+ const [chiefDefaults, chiefConfig] = await Promise.all([
495
+ loadChiefStaffDefaults(supabase, userId, staffRow.chief_agent_id).catch(() => null),
496
+ loadChiefAgentConfig(supabase, userId, staffRow.chief_agent_id).catch(() => null),
497
+ ]);
498
+ const resolved = resolveStaffModels(staffRow, chiefDefaults, chiefConfig);
499
+ staffPrefix = buildStaffPersonaBlock(staffRow, resolved) + "\n\n---\n\n";
500
+ api.logger?.debug?.(
501
+ `[ofiere-staff] subagent ${subagentId} (${staffRow.name}) reporting to ${resolvedAgentId} — persona injected; resolved models: ${JSON.stringify(resolved)}`,
502
+ );
503
+ } else if (staffRow) {
504
+ api.logger?.warn?.(
505
+ `[ofiere-staff] subagent ${subagentId} chief_mismatch (got ${staffRow.chief_agent_id}, expected ${resolvedAgentId}) — ignoring persona`,
506
+ );
507
+ staffRow = null;
508
+ } else {
509
+ api.logger?.warn?.(`[ofiere-staff] subagent ${subagentId} not found — ignoring persona`);
510
+ }
511
+ }
512
+
513
+ // wrap any return-block with the staff prefix (or pass through unchanged
514
+ // when no staff dispatch).
515
+ const wrap = (block: string | undefined | null): { appendSystemContext: string } | undefined => {
516
+ const finalBlock = staffPrefix + (block || "");
517
+ return finalBlock ? { appendSystemContext: finalBlock } : undefined;
518
+ };
519
+
520
+ // Dispatch-params path: workflow executor + task-dispatcher edge function
521
+ // can stash explicit `attached_sop_ids` / `attached_framework_ids` on the
522
+ // chat.send frame's metadata. When present, prefer them over the
523
+ // most-recent-conversation lookup and bypass cache (per-dispatch ids).
524
+ let dispatchIds = readDispatchAttachmentIds(ctx);
525
+ // Cycle 7b BUGSHOOT-1 (BUG 2) — DB-backed dispatch-context fallback for
526
+ // attachment ids. Same rationale as the subagent_id fallback above.
527
+ if (!dispatchIds.sopIds.length && !dispatchIds.fwIds.length && sessionKey) {
528
+ if (!dispatchCtxRow) {
529
+ dispatchCtxRow = await loadDispatchContextBySession(supabase, userId, sessionKey).catch(() => null);
530
+ }
531
+ if (dispatchCtxRow && (dispatchCtxRow.attached_sop_ids.length || dispatchCtxRow.attached_framework_ids.length)) {
532
+ dispatchIds = {
533
+ sopIds: dispatchCtxRow.attached_sop_ids,
534
+ fwIds: dispatchCtxRow.attached_framework_ids,
535
+ };
536
+ api.logger?.debug?.(
537
+ `[ofiere-attach] dispatch ids via dispatch_context (session=${sessionKey}) sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
538
+ );
539
+ }
540
+ }
541
+ if (dispatchIds.sopIds.length || dispatchIds.fwIds.length) {
542
+ api.logger?.debug?.(
543
+ `[ofiere-attach] dispatch ids agent=${resolvedAgentId} sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
544
+ );
545
+ const block = await renderBlockForIds({
546
+ supabase, userId,
547
+ sopIds: dispatchIds.sopIds,
548
+ fwIds: dispatchIds.fwIds,
549
+ });
550
+ return wrap(block);
551
+ }
552
+
553
+ // Staff-mode runs bypass the chief-level attachment cache so a chief
554
+ // and its staff never share a cached attachment block.
555
+ if (subagentId) {
556
+ const block = await buildAttachmentBlock({
557
+ supabase, userId, agentId: resolvedAgentId, subagentId,
558
+ });
559
+ return wrap(block);
560
+ }
561
+
562
+ // Multi-tenant: a single plugin process can serve multiple users — key
563
+ // by (userId, agentId) so User B never sees User A's cached block.
564
+ const cacheKey = `${userId}::${resolvedAgentId}`;
565
+ const cached = attachmentCache.get(cacheKey);
566
+ if (cached) {
567
+ const age = Date.now() - cached.at;
568
+ if (age < ATTACH_CACHE_TTL_MS) {
569
+ return wrap(cached.text);
570
+ }
571
+ if (age < ATTACH_CACHE_STALE_MS) {
572
+ // Refresh in background
573
+ (async () => {
574
+ try {
575
+ const fresh = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
576
+ attachmentCache.set(cacheKey, { text: fresh, at: Date.now() });
577
+ } catch { /* ignore */ }
578
+ })();
579
+ return wrap(cached.text);
580
+ }
581
+ }
582
+
583
+ const block = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
584
+ attachmentCache.set(cacheKey, { text: block, at: Date.now() });
585
+ return wrap(block);
586
+ } catch (e) {
587
+ api.logger?.debug?.(`[ofiere-attach] before_prompt_build error: ${e instanceof Error ? e.message : e}`);
588
+ }
589
+ });
590
+ } catch {
591
+ api.logger?.debug?.("[ofiere] Could not register attachment context hook — appendSystemContext may not be supported");
592
+ }
593
+ }
594
+
595
+ // Public for tests / future hooks that need to drop the cache
596
+ export function invalidateAttachmentCache(): void {
597
+ attachmentCache.clear();
598
+ }
599
+
600
+ // Re-export tier invalidation so callers don't need to import two modules
601
+ export { invalidateAgentTier };