ofiere-openclaw-plugin 4.54.1 → 4.55.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/README.md +104 -104
- package/dist/src/prompt.js +130 -130
- package/dist/src/staffPersona.js +3 -1
- package/dist/src/tools.js +121 -2
- package/index.ts +105 -105
- package/package.json +12 -2
- package/src/agent-resolver.ts +90 -90
- package/src/agent-tier.ts +192 -192
- package/src/attach-token.ts +106 -106
- package/src/attachments.ts +601 -601
- package/src/cli.ts +247 -247
- package/src/config.ts +78 -78
- package/src/prompt.ts +267 -267
- package/src/sop-render.ts +216 -216
- package/src/staffPersona.ts +299 -289
- package/src/supabase.ts +13 -13
- package/src/tools.ts +122 -2
- package/src/types/openclaw.d.ts +8 -8
- package/src/types.ts +10 -10
package/src/attachments.ts
CHANGED
|
@@ -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 };
|