ofiere-openclaw-plugin 4.37.2 → 4.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-tier.ts +144 -0
- package/src/attach-token.ts +106 -0
- package/src/attachments.ts +448 -0
- package/src/prompt.ts +10 -3
- package/src/sop-render.ts +216 -0
- package/src/tools.ts +32 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofiere-openclaw-plugin",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.39.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin for Ofiere PM - 16 meta-tools covering tasks, agents, projects, scheduling, knowledge, workflows, notifications, memory, prompts, constellation, space file management, execution plan builder, SOP management, agent brain, talent management, and corporate frameworks",
|
|
6
6
|
"keywords": ["openclaw", "ofiere", "project-management", "agents", "plugin"],
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// src/agent-tier.ts — Plugin-side agent tier resolver.
|
|
2
|
+
// Manual override (agent_tier_overrides) wins over auto-detection.
|
|
3
|
+
// Mirrors dashboard/lib/agentTier.ts contracts so the dashboard UI
|
|
4
|
+
// and plugin enforcement agree on every agent's tier.
|
|
5
|
+
|
|
6
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
7
|
+
|
|
8
|
+
export type AgentTier = "c-suite" | "staff" | null;
|
|
9
|
+
export type TierSource =
|
|
10
|
+
| "manual"
|
|
11
|
+
| "executiveRole"
|
|
12
|
+
| "roster"
|
|
13
|
+
| "subagent"
|
|
14
|
+
| "departmentRole"
|
|
15
|
+
| "none";
|
|
16
|
+
|
|
17
|
+
export interface TierResolution {
|
|
18
|
+
tier: AgentTier;
|
|
19
|
+
source: TierSource;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Hard-coded executive roster ids — match dashboard/lib/agentRoster.ts.
|
|
23
|
+
// Anything in this set defaults to c-suite when no manual override exists.
|
|
24
|
+
const ROSTER_IDS = new Set<string>([
|
|
25
|
+
"ofie",
|
|
26
|
+
"daisy",
|
|
27
|
+
"ivy",
|
|
28
|
+
"celia",
|
|
29
|
+
"thalia",
|
|
30
|
+
"sasha",
|
|
31
|
+
"agent-zero",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
interface CacheEntry { value: TierResolution; expires: number; }
|
|
35
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
36
|
+
const cache = new Map<string, CacheEntry>();
|
|
37
|
+
const k = (userId: string, agentId: string) => `${userId}::${agentId}`;
|
|
38
|
+
|
|
39
|
+
export function invalidateAgentTier(userId: string, agentId?: string): void {
|
|
40
|
+
if (!agentId) {
|
|
41
|
+
for (const key of cache.keys()) if (key.startsWith(`${userId}::`)) cache.delete(key);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
cache.delete(k(userId, agentId));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function resolveAgentTier(
|
|
48
|
+
supabase: SupabaseClient,
|
|
49
|
+
agentId: string,
|
|
50
|
+
userId: string,
|
|
51
|
+
): Promise<TierResolution> {
|
|
52
|
+
const id = (agentId || "").trim();
|
|
53
|
+
if (!id || !userId) return { tier: null, source: "none" };
|
|
54
|
+
|
|
55
|
+
const key = k(userId, id);
|
|
56
|
+
const hit = cache.get(key);
|
|
57
|
+
if (hit && hit.expires > Date.now()) return hit.value;
|
|
58
|
+
|
|
59
|
+
let result: TierResolution = { tier: null, source: "none" };
|
|
60
|
+
|
|
61
|
+
// 1. Manual override
|
|
62
|
+
try {
|
|
63
|
+
const { data } = await supabase
|
|
64
|
+
.from("agent_tier_overrides")
|
|
65
|
+
.select("tier")
|
|
66
|
+
.eq("user_id", userId)
|
|
67
|
+
.eq("agent_id", id)
|
|
68
|
+
.maybeSingle();
|
|
69
|
+
if (data?.tier === "c-suite" || data?.tier === "staff") {
|
|
70
|
+
result = { tier: data.tier, source: "manual" };
|
|
71
|
+
cache.set(key, { value: result, expires: Date.now() + TTL_MS });
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Table missing in older installs — fall through
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. C-Suite: hardcoded roster
|
|
79
|
+
if (ROSTER_IDS.has(id)) {
|
|
80
|
+
result = { tier: "c-suite", source: "roster" };
|
|
81
|
+
cache.set(key, { value: result, expires: Date.now() + TTL_MS });
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. agent_architectures.executive_role / department_role
|
|
86
|
+
try {
|
|
87
|
+
const { data } = await supabase
|
|
88
|
+
.from("agent_architectures")
|
|
89
|
+
.select("executive_role, department_role")
|
|
90
|
+
.eq("user_id", userId)
|
|
91
|
+
.eq("agent_id", id)
|
|
92
|
+
.maybeSingle();
|
|
93
|
+
if (data?.department_role === "chief" ||
|
|
94
|
+
(data?.executive_role && String(data.executive_role).trim())) {
|
|
95
|
+
result = { tier: "c-suite", source: "executiveRole" };
|
|
96
|
+
cache.set(key, { value: result, expires: Date.now() + TTL_MS });
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
if (data?.department_role === "staff") {
|
|
100
|
+
result = { tier: "staff", source: "departmentRole" };
|
|
101
|
+
cache.set(key, { value: result, expires: Date.now() + TTL_MS });
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Table missing — fall through
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Staff: registered as subagent
|
|
109
|
+
try {
|
|
110
|
+
const { data } = await supabase
|
|
111
|
+
.from("agent_subagents")
|
|
112
|
+
.select("id")
|
|
113
|
+
.eq("user_id", userId)
|
|
114
|
+
.eq("id", id)
|
|
115
|
+
.maybeSingle();
|
|
116
|
+
if (data?.id) {
|
|
117
|
+
result = { tier: "staff", source: "subagent" };
|
|
118
|
+
cache.set(key, { value: result, expires: Date.now() + TTL_MS });
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
cache.set(key, { value: result, expires: Date.now() + TTL_MS });
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getAgentTier(
|
|
130
|
+
supabase: SupabaseClient,
|
|
131
|
+
agentId: string,
|
|
132
|
+
userId: string,
|
|
133
|
+
): Promise<AgentTier> {
|
|
134
|
+
return (await resolveAgentTier(supabase, agentId, userId)).tier;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isDocKindValidForTier(
|
|
138
|
+
docKind: "sop" | "framework",
|
|
139
|
+
tier: AgentTier,
|
|
140
|
+
): boolean {
|
|
141
|
+
if (tier === "c-suite") return docKind === "framework";
|
|
142
|
+
if (tier === "staff") return docKind === "sop";
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/attach-token.ts — HMAC confirmation token for agent self-attach.
|
|
2
|
+
// Mirrors dashboard/lib/attachmentToken.ts EXACTLY so tokens issued
|
|
3
|
+
// here verify against the dashboard and vice-versa.
|
|
4
|
+
//
|
|
5
|
+
// Format: base64url(payload).base64url(sig)
|
|
6
|
+
// payload = JSON({ u, k, t, d, dk, exp })
|
|
7
|
+
// sig = HMAC_SHA256(secret, payload)
|
|
8
|
+
|
|
9
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
10
|
+
|
|
11
|
+
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
function getSecret(): string {
|
|
14
|
+
const s = process.env.OFIERE_ATTACH_SECRET;
|
|
15
|
+
if (!s) throw new Error("OFIERE_ATTACH_SECRET is not configured");
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function b64urlEncode(buf: Buffer | string): string {
|
|
20
|
+
const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf;
|
|
21
|
+
return b.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function b64urlDecode(s: string): Buffer {
|
|
25
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
|
26
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TokenPayload {
|
|
30
|
+
u: string;
|
|
31
|
+
k: string;
|
|
32
|
+
t: string;
|
|
33
|
+
d: string[];
|
|
34
|
+
dk: "sop" | "framework";
|
|
35
|
+
exp: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IssueArgs {
|
|
39
|
+
userId: string;
|
|
40
|
+
targetKind: string;
|
|
41
|
+
targetId: string;
|
|
42
|
+
docKind: "sop" | "framework";
|
|
43
|
+
docIds: string[];
|
|
44
|
+
ttlMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function issueAttachmentToken(args: IssueArgs): string {
|
|
48
|
+
const exp = Date.now() + (args.ttlMs ?? TOKEN_TTL_MS);
|
|
49
|
+
const payload: TokenPayload = {
|
|
50
|
+
u: args.userId,
|
|
51
|
+
k: args.targetKind,
|
|
52
|
+
t: args.targetId,
|
|
53
|
+
d: [...args.docIds].sort(),
|
|
54
|
+
dk: args.docKind,
|
|
55
|
+
exp,
|
|
56
|
+
};
|
|
57
|
+
const json = JSON.stringify(payload);
|
|
58
|
+
const sig = createHmac("sha256", getSecret()).update(json).digest();
|
|
59
|
+
return `${b64urlEncode(json)}.${b64urlEncode(sig)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface VerifyArgs {
|
|
63
|
+
token: string;
|
|
64
|
+
userId: string;
|
|
65
|
+
targetKind: string;
|
|
66
|
+
targetId: string;
|
|
67
|
+
docKind: "sop" | "framework";
|
|
68
|
+
docIds: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type VerifyResult =
|
|
72
|
+
| { ok: true }
|
|
73
|
+
| { ok: false; reason: "malformed" | "bad_signature" | "expired" | "mismatch" };
|
|
74
|
+
|
|
75
|
+
export function verifyAttachmentToken(args: VerifyArgs): VerifyResult {
|
|
76
|
+
const parts = args.token.split(".");
|
|
77
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
78
|
+
let payload: TokenPayload;
|
|
79
|
+
try {
|
|
80
|
+
payload = JSON.parse(b64urlDecode(parts[0]).toString("utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return { ok: false, reason: "malformed" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const expectedJson = JSON.stringify(payload);
|
|
86
|
+
const expectedSig = createHmac("sha256", getSecret()).update(expectedJson).digest();
|
|
87
|
+
const providedSig = b64urlDecode(parts[1]);
|
|
88
|
+
if (expectedSig.length !== providedSig.length) return { ok: false, reason: "bad_signature" };
|
|
89
|
+
if (!timingSafeEqual(expectedSig, providedSig)) return { ok: false, reason: "bad_signature" };
|
|
90
|
+
|
|
91
|
+
if (typeof payload.exp !== "number" || payload.exp < Date.now()) {
|
|
92
|
+
return { ok: false, reason: "expired" };
|
|
93
|
+
}
|
|
94
|
+
if (payload.u !== args.userId) return { ok: false, reason: "mismatch" };
|
|
95
|
+
if (payload.k !== args.targetKind || payload.t !== args.targetId) return { ok: false, reason: "mismatch" };
|
|
96
|
+
if (payload.dk !== args.docKind) return { ok: false, reason: "mismatch" };
|
|
97
|
+
|
|
98
|
+
const sortedRequested = [...args.docIds].sort();
|
|
99
|
+
if (
|
|
100
|
+
payload.d.length !== sortedRequested.length ||
|
|
101
|
+
payload.d.some((v, i) => v !== sortedRequested[i])
|
|
102
|
+
) {
|
|
103
|
+
return { ok: false, reason: "mismatch" };
|
|
104
|
+
}
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
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
|
+
resolveAgentTier,
|
|
14
|
+
isDocKindValidForTier,
|
|
15
|
+
invalidateAgentTier,
|
|
16
|
+
} from "./agent-tier.js";
|
|
17
|
+
import { issueAttachmentToken, verifyAttachmentToken } from "./attach-token.js";
|
|
18
|
+
|
|
19
|
+
interface ToolResult {
|
|
20
|
+
content: Array<{ type: "text"; text: string }>;
|
|
21
|
+
}
|
|
22
|
+
function ok(data: unknown): ToolResult {
|
|
23
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
24
|
+
}
|
|
25
|
+
function err(message: string): ToolResult {
|
|
26
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TargetKind = "conversation" | "task" | "scheduler_event";
|
|
30
|
+
export type DocKind = "sop" | "framework";
|
|
31
|
+
|
|
32
|
+
const VALID_TARGETS: ReadonlyArray<TargetKind> = ["conversation", "task", "scheduler_event"];
|
|
33
|
+
function isValidTargetKind(v: unknown): v is TargetKind {
|
|
34
|
+
return typeof v === "string" && (VALID_TARGETS as readonly string[]).includes(v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Token cost (char/4 heuristic, matches dashboard) ──
|
|
38
|
+
function estimateTokens(content: unknown): number {
|
|
39
|
+
if (content == null) return 0;
|
|
40
|
+
const str = typeof content === "string" ? content : JSON.stringify(content);
|
|
41
|
+
return Math.ceil(str.length / 4);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Attachment write logic ──
|
|
45
|
+
async function applyAttachmentWrite(args: {
|
|
46
|
+
supabase: SupabaseClient;
|
|
47
|
+
userId: string;
|
|
48
|
+
targetKind: TargetKind;
|
|
49
|
+
targetId: string;
|
|
50
|
+
docKind: DocKind;
|
|
51
|
+
docIds: string[];
|
|
52
|
+
}): Promise<{ ok: boolean; error?: string }> {
|
|
53
|
+
const { supabase, userId, targetKind, targetId, docKind, docIds } = args;
|
|
54
|
+
if (!docIds.length) return { ok: true };
|
|
55
|
+
|
|
56
|
+
if (targetKind === "conversation") {
|
|
57
|
+
const col = docKind === "sop" ? "attached_sop_ids" : "attached_framework_ids";
|
|
58
|
+
const { data: cur, error: readErr } = await supabase
|
|
59
|
+
.from("conversations")
|
|
60
|
+
.select(col)
|
|
61
|
+
.eq("user_id", userId)
|
|
62
|
+
.eq("id", targetId)
|
|
63
|
+
.maybeSingle();
|
|
64
|
+
if (readErr) return { ok: false, error: readErr.message };
|
|
65
|
+
const existing = ((cur as Record<string, string[] | undefined> | null)?.[col]) || [];
|
|
66
|
+
const merged = Array.from(new Set([...existing, ...docIds]));
|
|
67
|
+
const { error: writeErr } = await supabase
|
|
68
|
+
.from("conversations")
|
|
69
|
+
.update({ [col]: merged, updated_at: new Date().toISOString() })
|
|
70
|
+
.eq("user_id", userId)
|
|
71
|
+
.eq("id", targetId);
|
|
72
|
+
if (writeErr) return { ok: false, error: writeErr.message };
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (targetKind === "task" || targetKind === "scheduler_event") {
|
|
77
|
+
const tbl = targetKind === "task" ? "tasks" : "scheduler_events";
|
|
78
|
+
const fieldKey = docKind === "sop" ? "sop_ids" : "framework_ids";
|
|
79
|
+
const { data: cur, error: readErr } = await supabase
|
|
80
|
+
.from(tbl)
|
|
81
|
+
.select("custom_fields")
|
|
82
|
+
.eq("user_id", userId)
|
|
83
|
+
.eq("id", targetId)
|
|
84
|
+
.maybeSingle();
|
|
85
|
+
if (readErr) return { ok: false, error: readErr.message };
|
|
86
|
+
const cf = (cur?.custom_fields as Record<string, unknown> | null) || {};
|
|
87
|
+
const existing: string[] = Array.isArray(cf?.[fieldKey]) ? (cf[fieldKey] as string[]) : [];
|
|
88
|
+
const merged = Array.from(new Set([...existing, ...docIds]));
|
|
89
|
+
const nextCf = { ...cf, [fieldKey]: merged };
|
|
90
|
+
const { error: writeErr } = await supabase
|
|
91
|
+
.from(tbl)
|
|
92
|
+
.update({ custom_fields: nextCf, updated_at: new Date().toISOString() })
|
|
93
|
+
.eq("user_id", userId)
|
|
94
|
+
.eq("id", targetId);
|
|
95
|
+
if (writeErr) return { ok: false, error: writeErr.message };
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ok: false, error: "unsupported target_kind" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Load target row (mainly to read its agent_id for tier check) ──
|
|
103
|
+
async function loadTargetAgentId(args: {
|
|
104
|
+
supabase: SupabaseClient;
|
|
105
|
+
userId: string;
|
|
106
|
+
targetKind: TargetKind;
|
|
107
|
+
targetId: string;
|
|
108
|
+
}): Promise<string | null> {
|
|
109
|
+
const { supabase, userId, targetKind, targetId } = args;
|
|
110
|
+
const tbl =
|
|
111
|
+
targetKind === "conversation" ? "conversations" :
|
|
112
|
+
targetKind === "task" ? "tasks" : "scheduler_events";
|
|
113
|
+
const { data } = await supabase
|
|
114
|
+
.from(tbl)
|
|
115
|
+
.select("agent_id")
|
|
116
|
+
.eq("user_id", userId)
|
|
117
|
+
.eq("id", targetId)
|
|
118
|
+
.maybeSingle();
|
|
119
|
+
return (data?.agent_id as string) || null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Validate that every doc id belongs to userId in the right table ──
|
|
123
|
+
async function validateDocIds(args: {
|
|
124
|
+
supabase: SupabaseClient;
|
|
125
|
+
userId: string;
|
|
126
|
+
docKind: DocKind;
|
|
127
|
+
docIds: string[];
|
|
128
|
+
}): Promise<{ ok: boolean; matched: string[] }> {
|
|
129
|
+
const { supabase, userId, docKind, docIds } = args;
|
|
130
|
+
if (!docIds.length) return { ok: true, matched: [] };
|
|
131
|
+
const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
|
|
132
|
+
const { data } = await supabase
|
|
133
|
+
.from(tbl)
|
|
134
|
+
.select("id")
|
|
135
|
+
.eq("user_id", userId)
|
|
136
|
+
.in("id", docIds);
|
|
137
|
+
const matched = (data || []).map((r: { id: string }) => r.id);
|
|
138
|
+
return { ok: matched.length === docIds.length, matched };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── propose_attach: validate + compute cost + issue token ──
|
|
142
|
+
export async function handleProposeAttach(args: {
|
|
143
|
+
supabase: SupabaseClient;
|
|
144
|
+
userId: string;
|
|
145
|
+
docKind: DocKind;
|
|
146
|
+
params: Record<string, unknown>;
|
|
147
|
+
}): Promise<ToolResult> {
|
|
148
|
+
const { supabase, userId, docKind, params } = args;
|
|
149
|
+
if (!isValidTargetKind(params.target_kind)) {
|
|
150
|
+
return err("Invalid target_kind. Use one of: conversation, task, scheduler_event");
|
|
151
|
+
}
|
|
152
|
+
const targetKind = params.target_kind as TargetKind;
|
|
153
|
+
const targetId = params.target_id as string;
|
|
154
|
+
if (!targetId) return err("Missing target_id");
|
|
155
|
+
if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
|
|
156
|
+
return err("Missing doc_ids[]");
|
|
157
|
+
}
|
|
158
|
+
const docIds = (params.doc_ids as unknown[]).filter(
|
|
159
|
+
(x): x is string => typeof x === "string",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const targetAgentId = await loadTargetAgentId({
|
|
163
|
+
supabase, userId, targetKind, targetId,
|
|
164
|
+
});
|
|
165
|
+
if (!targetAgentId) return err("target_not_found_or_no_agent");
|
|
166
|
+
|
|
167
|
+
const tierResolution = await resolveAgentTier(supabase, targetAgentId, userId);
|
|
168
|
+
if (!tierResolution.tier) {
|
|
169
|
+
return err("agent_unclassified — set tier in dashboard agent settings or via override");
|
|
170
|
+
}
|
|
171
|
+
if (!isDocKindValidForTier(docKind, tierResolution.tier)) {
|
|
172
|
+
return err(
|
|
173
|
+
`tier_mismatch — assigned agent is ${tierResolution.tier}; ` +
|
|
174
|
+
(docKind === "sop"
|
|
175
|
+
? "SOPs only attach to staff"
|
|
176
|
+
: "Frameworks only attach to c-suite"),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const v = await validateDocIds({ supabase, userId, docKind, docIds });
|
|
181
|
+
if (!v.ok) return err("unknown_doc — one or more doc_ids do not belong to you");
|
|
182
|
+
|
|
183
|
+
// Cost summary
|
|
184
|
+
const tbl = docKind === "sop" ? "agent_sops" : "frameworks";
|
|
185
|
+
const { data: rows } = await supabase
|
|
186
|
+
.from(tbl)
|
|
187
|
+
.select("id, title, content")
|
|
188
|
+
.eq("user_id", userId)
|
|
189
|
+
.in("id", docIds);
|
|
190
|
+
const breakdown = (rows || []).map((r: { id: string; title: string; content: string }) => ({
|
|
191
|
+
id: r.id,
|
|
192
|
+
title: r.title || (docKind === "sop" ? "Untitled SOP" : "Untitled Framework"),
|
|
193
|
+
tokens: estimateTokens(r.content),
|
|
194
|
+
kind: docKind,
|
|
195
|
+
}));
|
|
196
|
+
const totalTokens = breakdown.reduce((acc, b) => acc + b.tokens, 0);
|
|
197
|
+
|
|
198
|
+
let token: string;
|
|
199
|
+
try {
|
|
200
|
+
token = issueAttachmentToken({
|
|
201
|
+
userId, targetKind, targetId, docKind, docIds,
|
|
202
|
+
});
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return err(`token_issue_failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const noun = docKind === "sop" ? "SOP" : "Framework";
|
|
208
|
+
const plural = docIds.length === 1 ? noun : `${noun}s`;
|
|
209
|
+
const summary = `Adding ${docIds.length} ${plural} (~${totalTokens.toLocaleString()} tokens) to ${targetKind} ${targetId}.`;
|
|
210
|
+
|
|
211
|
+
return ok({
|
|
212
|
+
requires_confirmation: true,
|
|
213
|
+
target_kind: targetKind,
|
|
214
|
+
target_id: targetId,
|
|
215
|
+
doc_kind: docKind,
|
|
216
|
+
doc_ids: docIds,
|
|
217
|
+
tier: tierResolution.tier,
|
|
218
|
+
tier_source: tierResolution.source,
|
|
219
|
+
token_cost: totalTokens,
|
|
220
|
+
breakdown,
|
|
221
|
+
confirmation_token: token,
|
|
222
|
+
confirmation_prompt:
|
|
223
|
+
`${summary} Ask the user: "Proceed? (~${totalTokens.toLocaleString()} tokens)" — only call commit_attach AFTER user approves.`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── commit_attach: verify token + write ──
|
|
228
|
+
export async function handleCommitAttach(args: {
|
|
229
|
+
supabase: SupabaseClient;
|
|
230
|
+
userId: string;
|
|
231
|
+
docKind: DocKind;
|
|
232
|
+
params: Record<string, unknown>;
|
|
233
|
+
}): Promise<ToolResult> {
|
|
234
|
+
const { supabase, userId, docKind, params } = args;
|
|
235
|
+
if (!isValidTargetKind(params.target_kind)) return err("Invalid target_kind");
|
|
236
|
+
const targetKind = params.target_kind as TargetKind;
|
|
237
|
+
const targetId = params.target_id as string;
|
|
238
|
+
if (!targetId) return err("Missing target_id");
|
|
239
|
+
if (!Array.isArray(params.doc_ids) || params.doc_ids.length === 0) {
|
|
240
|
+
return err("Missing doc_ids[]");
|
|
241
|
+
}
|
|
242
|
+
const docIds = (params.doc_ids as unknown[]).filter(
|
|
243
|
+
(x): x is string => typeof x === "string",
|
|
244
|
+
);
|
|
245
|
+
const token = params.confirmation_token as string;
|
|
246
|
+
if (!token) return err("Missing confirmation_token (call propose_attach first)");
|
|
247
|
+
|
|
248
|
+
const v = verifyAttachmentToken({
|
|
249
|
+
token, userId, targetKind, targetId, docKind, docIds,
|
|
250
|
+
});
|
|
251
|
+
if (!v.ok) return err(`token_${v.reason}`);
|
|
252
|
+
|
|
253
|
+
const wr = await applyAttachmentWrite({
|
|
254
|
+
supabase, userId, targetKind, targetId, docKind, docIds,
|
|
255
|
+
});
|
|
256
|
+
if (!wr.ok) return err(wr.error || "write_failed");
|
|
257
|
+
|
|
258
|
+
// Invalidate the per-agent attachment cache so the next prompt build picks up the new ids
|
|
259
|
+
attachmentCache.clear();
|
|
260
|
+
|
|
261
|
+
return ok({
|
|
262
|
+
ok: true,
|
|
263
|
+
attached: docIds.length,
|
|
264
|
+
target_kind: targetKind,
|
|
265
|
+
target_id: targetId,
|
|
266
|
+
doc_kind: docKind,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── before_prompt_build hook ──
|
|
271
|
+
const attachmentCache = new Map<string, { text: string; at: number }>();
|
|
272
|
+
const ATTACH_CACHE_TTL_MS = 600_000;
|
|
273
|
+
const ATTACH_CACHE_STALE_MS = 900_000;
|
|
274
|
+
|
|
275
|
+
function isSystemName(name: string): boolean {
|
|
276
|
+
const s = name.toLowerCase().trim();
|
|
277
|
+
return s === "" || s === "ofiere" || s === "openclaw" || s === "system" || s === "plugin" || s === "gateway" || s.includes("plugin");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Render a block from explicit doc-id lists. Used by both the dispatch-params
|
|
281
|
+
// path (when workflow/task-dispatcher hands ids in via ctx) and the conversation
|
|
282
|
+
// fallback path (when no explicit ids are present).
|
|
283
|
+
async function renderBlockForIds(args: {
|
|
284
|
+
supabase: SupabaseClient;
|
|
285
|
+
userId: string;
|
|
286
|
+
sopIds: string[];
|
|
287
|
+
fwIds: string[];
|
|
288
|
+
}): Promise<string> {
|
|
289
|
+
const { supabase, userId, sopIds, fwIds } = args;
|
|
290
|
+
if (!sopIds.length && !fwIds.length) return "";
|
|
291
|
+
|
|
292
|
+
const [sopsRes, fwsRes] = await Promise.all([
|
|
293
|
+
sopIds.length
|
|
294
|
+
? supabase
|
|
295
|
+
.from("agent_sops")
|
|
296
|
+
.select("title, content")
|
|
297
|
+
.eq("user_id", userId)
|
|
298
|
+
.in("id", sopIds)
|
|
299
|
+
: Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
|
|
300
|
+
fwIds.length
|
|
301
|
+
? supabase
|
|
302
|
+
.from("frameworks")
|
|
303
|
+
.select("title, content")
|
|
304
|
+
.eq("user_id", userId)
|
|
305
|
+
.in("id", fwIds)
|
|
306
|
+
: Promise.resolve({ data: [] as Array<{ title: string; content: string }> }),
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
const sops = (sopsRes.data || []).map((s) => ({
|
|
310
|
+
title: s.title || "Untitled SOP",
|
|
311
|
+
content: typeof s.content === "string" ? s.content : JSON.stringify(s.content ?? ""),
|
|
312
|
+
}));
|
|
313
|
+
const frameworks = (fwsRes.data || []).map((f) => ({
|
|
314
|
+
title: f.title || "Untitled Framework",
|
|
315
|
+
content: typeof f.content === "string" ? f.content : JSON.stringify(f.content ?? ""),
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
return renderAttachmentBlock({ sops, frameworks });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Pluck attachment ids from the `before_prompt_build` ctx. Exact path varies by
|
|
322
|
+
// the dispatch surface that fires the prompt build, so we probe several known
|
|
323
|
+
// locations. Workflow executor + task-dispatcher edge function both stash ids
|
|
324
|
+
// under `metadata` on their request frames.
|
|
325
|
+
function readDispatchAttachmentIds(ctx: any): { sopIds: string[]; fwIds: string[] } {
|
|
326
|
+
const candidates: Array<Record<string, unknown> | undefined> = [
|
|
327
|
+
ctx?.metadata,
|
|
328
|
+
ctx?.params?.metadata,
|
|
329
|
+
ctx?.payload?.metadata,
|
|
330
|
+
ctx?.request?.metadata,
|
|
331
|
+
ctx?.options?.metadata,
|
|
332
|
+
ctx, // last resort: top level
|
|
333
|
+
];
|
|
334
|
+
for (const c of candidates) {
|
|
335
|
+
if (!c || typeof c !== "object") continue;
|
|
336
|
+
const sop = (c as any).attached_sop_ids;
|
|
337
|
+
const fw = (c as any).attached_framework_ids;
|
|
338
|
+
if (Array.isArray(sop) || Array.isArray(fw)) {
|
|
339
|
+
return {
|
|
340
|
+
sopIds: Array.isArray(sop) ? sop.filter((x: unknown): x is string => typeof x === "string") : [],
|
|
341
|
+
fwIds: Array.isArray(fw) ? fw.filter((x: unknown): x is string => typeof x === "string") : [],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { sopIds: [], fwIds: [] };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function buildAttachmentBlock(args: {
|
|
349
|
+
supabase: SupabaseClient;
|
|
350
|
+
userId: string;
|
|
351
|
+
agentId: string;
|
|
352
|
+
}): Promise<string> {
|
|
353
|
+
const { supabase, userId, agentId } = args;
|
|
354
|
+
|
|
355
|
+
// Find the most recently active conversation for this agent. The dashboard
|
|
356
|
+
// bumps `updated_at` whenever a message is sent or attachments change, so
|
|
357
|
+
// this gives us the right run target most of the time.
|
|
358
|
+
const { data: convRow } = await supabase
|
|
359
|
+
.from("conversations")
|
|
360
|
+
.select("id, attached_sop_ids, attached_framework_ids")
|
|
361
|
+
.eq("user_id", userId)
|
|
362
|
+
.eq("agent_id", agentId)
|
|
363
|
+
.order("updated_at", { ascending: false })
|
|
364
|
+
.limit(1)
|
|
365
|
+
.maybeSingle();
|
|
366
|
+
|
|
367
|
+
const sopIds: string[] = (convRow?.attached_sop_ids as string[] | null) || [];
|
|
368
|
+
const fwIds: string[] = (convRow?.attached_framework_ids as string[] | null) || [];
|
|
369
|
+
return renderBlockForIds({ supabase, userId, sopIds, fwIds });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function registerAttachmentContextHook(args: {
|
|
373
|
+
api: any;
|
|
374
|
+
supabase: SupabaseClient;
|
|
375
|
+
userId: string;
|
|
376
|
+
fallbackAgentId: string;
|
|
377
|
+
resolveAgent: (id?: string) => Promise<string | null>;
|
|
378
|
+
}): void {
|
|
379
|
+
const { api, supabase, userId, fallbackAgentId, resolveAgent } = args;
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
api.on("before_prompt_build", async (_event: any, ctx: any) => {
|
|
383
|
+
try {
|
|
384
|
+
const ctxAgentId = ctx?.agentId || "";
|
|
385
|
+
let resolvedAgentId = fallbackAgentId;
|
|
386
|
+
if (ctxAgentId && !isSystemName(ctxAgentId)) {
|
|
387
|
+
try {
|
|
388
|
+
const r = await resolveAgent(ctxAgentId);
|
|
389
|
+
if (r) resolvedAgentId = r;
|
|
390
|
+
} catch { /* fall through */ }
|
|
391
|
+
}
|
|
392
|
+
if (!resolvedAgentId) return;
|
|
393
|
+
|
|
394
|
+
// Dispatch-params path: workflow executor + task-dispatcher edge function
|
|
395
|
+
// can stash explicit `attached_sop_ids` / `attached_framework_ids` on the
|
|
396
|
+
// chat.send frame's metadata. When present, prefer them over the
|
|
397
|
+
// most-recent-conversation lookup and bypass cache (per-dispatch ids).
|
|
398
|
+
const dispatchIds = readDispatchAttachmentIds(ctx);
|
|
399
|
+
if (dispatchIds.sopIds.length || dispatchIds.fwIds.length) {
|
|
400
|
+
api.logger?.debug?.(
|
|
401
|
+
`[ofiere-attach] dispatch ids agent=${resolvedAgentId} sops=${dispatchIds.sopIds.length} fws=${dispatchIds.fwIds.length}`,
|
|
402
|
+
);
|
|
403
|
+
const block = await renderBlockForIds({
|
|
404
|
+
supabase, userId,
|
|
405
|
+
sopIds: dispatchIds.sopIds,
|
|
406
|
+
fwIds: dispatchIds.fwIds,
|
|
407
|
+
});
|
|
408
|
+
return block ? { appendSystemContext: block } : undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const cacheKey = resolvedAgentId;
|
|
412
|
+
const cached = attachmentCache.get(cacheKey);
|
|
413
|
+
if (cached) {
|
|
414
|
+
const age = Date.now() - cached.at;
|
|
415
|
+
if (age < ATTACH_CACHE_TTL_MS) {
|
|
416
|
+
return cached.text ? { appendSystemContext: cached.text } : undefined;
|
|
417
|
+
}
|
|
418
|
+
if (age < ATTACH_CACHE_STALE_MS) {
|
|
419
|
+
// Refresh in background
|
|
420
|
+
(async () => {
|
|
421
|
+
try {
|
|
422
|
+
const fresh = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
|
|
423
|
+
attachmentCache.set(cacheKey, { text: fresh, at: Date.now() });
|
|
424
|
+
} catch { /* ignore */ }
|
|
425
|
+
})();
|
|
426
|
+
return cached.text ? { appendSystemContext: cached.text } : undefined;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const block = await buildAttachmentBlock({ supabase, userId, agentId: resolvedAgentId });
|
|
431
|
+
attachmentCache.set(cacheKey, { text: block, at: Date.now() });
|
|
432
|
+
return block ? { appendSystemContext: block } : undefined;
|
|
433
|
+
} catch (e) {
|
|
434
|
+
api.logger?.debug?.(`[ofiere-attach] before_prompt_build error: ${e instanceof Error ? e.message : e}`);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
api.logger?.debug?.("[ofiere] Could not register attachment context hook — appendSystemContext may not be supported");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Public for tests / future hooks that need to drop the cache
|
|
443
|
+
export function invalidateAttachmentCache(): void {
|
|
444
|
+
attachmentCache.clear();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Re-export tier invalidation so callers don't need to import two modules
|
|
448
|
+
export { invalidateAgentTier };
|
package/src/prompt.ts
CHANGED
|
@@ -24,9 +24,10 @@ const TOOL_SUMMARIES: Record<string, string> = {
|
|
|
24
24
|
OFIERE_CONSTELLATION_OPS: "Create/edit/delete OpenClaw agents from chat",
|
|
25
25
|
OFIERE_FILE_OPS: "Space files: upload, read, move, share, delete",
|
|
26
26
|
OFIERE_PLAN_OPS: "Visual execution plan builder (DAG drafts → real tasks)",
|
|
27
|
-
OFIERE_SOP_OPS: "Standard Operating Procedures for
|
|
27
|
+
OFIERE_SOP_OPS: "Standard Operating Procedures (Staff agents). Includes propose_attach/commit_attach for self-attaching SOPs to runs",
|
|
28
28
|
OFIERE_BRAIN_OPS: "Agent memory, knowledge graph, self-improvement (TMT/MAGMA)",
|
|
29
29
|
OFIERE_TALENT_OPS: "Talents: list, get, activate, deactivate cognitive skill presets",
|
|
30
|
+
OFIERE_FRAMEWORK_OPS: "Corporate Frameworks (C-Suite agents). Includes propose_attach/commit_attach for self-attaching Frameworks to runs",
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
// ─── Tier B: Full Tool Documentation ────────────────────────────────────────
|
|
@@ -105,10 +106,11 @@ Actions: "list", "get", "create", "update", "delete", "add_nodes", "execute"
|
|
|
105
106
|
- Node types: task, gate, milestone
|
|
106
107
|
- Execution maps ALL fields: execution_steps, goals, constraints, system_prompt`,
|
|
107
108
|
|
|
108
|
-
OFIERE_SOP_OPS: `Standard Operating Procedures
|
|
109
|
-
Actions: "list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template"
|
|
109
|
+
OFIERE_SOP_OPS: `Standard Operating Procedures.
|
|
110
|
+
Actions: "list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template", "propose_attach", "commit_attach"
|
|
110
111
|
- sop_data: { title, purpose, scope, applicability, prerequisites:{ conditions, required_tools, required_permissions, safety_warnings }, procedure_steps[], expected_outputs[], escalation_rules[], acceptance_criteria[], rollback_procedure, notes }
|
|
111
112
|
- Legacy field names still accepted (objective→purpose, steps→procedure_steps, deliverables→expected_outputs, escalationRules→escalation_rules, successCriteria→acceptance_criteria) — prefer new names.
|
|
113
|
+
- propose_attach / commit_attach: attach SOPs to a run (target_kind: conversation|task|scheduler_event). Tier rule: SOPs only attach to STAFF-tier targets; for c-suite use OFIERE_FRAMEWORK_OPS instead. propose_attach returns a token-cost summary + confirmation_token (5-min ttl). You MUST surface the cost and ask the user before calling commit_attach. The user must explicitly approve.
|
|
112
114
|
- See SOP PROTOCOL section for when to load vs skip`,
|
|
113
115
|
|
|
114
116
|
OFIERE_BRAIN_OPS: `Agent memory, knowledge graph, self-improvement (TMT/MAGMA).
|
|
@@ -124,6 +126,11 @@ Actions: "list", "get", "activate", "deactivate"
|
|
|
124
126
|
- deactivate: Disable a talent (status → inactive). Required: talent_id
|
|
125
127
|
- Active talents inject their execution_protocol and guardrails into your system prompt automatically
|
|
126
128
|
- Talents chain other tools: SPHINX chains KNOWLEDGE_OPS+BRAIN_OPS, PRISM chains BRAIN_OPS trajectories, ATLAS chains PLAN_OPS+TASK_OPS+SOP_OPS`,
|
|
129
|
+
|
|
130
|
+
OFIERE_FRAMEWORK_OPS: `Corporate Frameworks — strategic mandates, KPIs, risk governance.
|
|
131
|
+
Actions: "create", "list", "get", "update", "delete", "propose_attach", "commit_attach"
|
|
132
|
+
- content: JSON string with { mission, vision, core_principles[], decision_authority, budget_constraints, resource_limits, tool_stack[], strategic_objectives[], risk_appetite, compliance_requirements[], escalation_matrix[], team_composition (STRING — paragraph or newline-joined, NOT an array), integration_points[], review_frequency, last_reviewed, next_review, notes }
|
|
133
|
+
- propose_attach / commit_attach: attach Frameworks to a run (target_kind: conversation|task|scheduler_event). Tier rule: Frameworks only attach to C-SUITE-tier targets; for staff use OFIERE_SOP_OPS instead. propose_attach returns a token-cost summary + confirmation_token (5-min ttl). You MUST surface the cost and ask the user before calling commit_attach. The user must explicitly approve.`,
|
|
127
134
|
};
|
|
128
135
|
|
|
129
136
|
export function getSystemPrompt(state: {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// src/sop-render.ts — Plugin-side markdown renderer for SOPs and Frameworks.
|
|
2
|
+
// Mirrors dashboard/lib/sopRender.ts. Output is what gets prepended/appended to
|
|
3
|
+
// the system prompt during `before_prompt_build`.
|
|
4
|
+
|
|
5
|
+
interface SOPCheckItem { text?: string; checked?: boolean; }
|
|
6
|
+
interface SOPStep {
|
|
7
|
+
name?: string; action?: string; owner?: string; output?: string;
|
|
8
|
+
decision_logic?: string; failure_state?: string; fallback_action?: string;
|
|
9
|
+
estimated_duration?: string;
|
|
10
|
+
}
|
|
11
|
+
interface SOPEscalationRule { trigger?: string; escalateTo?: string; priority?: string; }
|
|
12
|
+
interface SOPPrerequisites {
|
|
13
|
+
conditions?: SOPCheckItem[]; required_tools?: string[];
|
|
14
|
+
required_permissions?: string[]; safety_warnings?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface SOPData {
|
|
17
|
+
title?: string;
|
|
18
|
+
purpose?: string; scope?: string; applicability?: string;
|
|
19
|
+
prerequisites?: SOPPrerequisites;
|
|
20
|
+
procedure_steps?: SOPStep[];
|
|
21
|
+
expected_outputs?: string[];
|
|
22
|
+
acceptance_criteria?: SOPCheckItem[];
|
|
23
|
+
escalation_rules?: SOPEscalationRule[];
|
|
24
|
+
rollback_procedure?: string;
|
|
25
|
+
notes?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FrameworkObjective {
|
|
29
|
+
objective?: string; target?: string; measurement?: string; frequency?: string;
|
|
30
|
+
}
|
|
31
|
+
interface FrameworkEscalation {
|
|
32
|
+
trigger?: string; escalate_to?: string; priority?: string; response_sla?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface FrameworkData {
|
|
35
|
+
title?: string;
|
|
36
|
+
mission?: string; vision?: string;
|
|
37
|
+
core_principles?: string[];
|
|
38
|
+
decision_authority?: string; budget_constraints?: string;
|
|
39
|
+
resource_limits?: string; tool_stack?: string[];
|
|
40
|
+
strategic_objectives?: FrameworkObjective[];
|
|
41
|
+
risk_appetite?: string; compliance_requirements?: string[];
|
|
42
|
+
escalation_matrix?: FrameworkEscalation[];
|
|
43
|
+
team_composition?: string; integration_points?: string[];
|
|
44
|
+
review_frequency?: string; last_reviewed?: string; next_review?: string;
|
|
45
|
+
notes?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function bullet(items: (string | undefined | null)[]): string {
|
|
49
|
+
const cleaned = items.map((s) => (s ?? "").toString().trim()).filter(Boolean);
|
|
50
|
+
return cleaned.length ? cleaned.map((s) => `- ${s}`).join("\n") : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function section(label: string, body: string): string {
|
|
54
|
+
const trimmed = body.trim();
|
|
55
|
+
return trimmed ? `### ${label}\n${trimmed}` : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function joinSections(parts: string[]): string {
|
|
59
|
+
return parts.filter(Boolean).join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeParse<T = unknown>(s: string): T | null {
|
|
63
|
+
try { return JSON.parse(s) as T; } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderSopMarkdown(input: string | SOPData | null | undefined): string {
|
|
67
|
+
if (input == null) return "";
|
|
68
|
+
const data: SOPData = typeof input === "string"
|
|
69
|
+
? (safeParse<SOPData>(input) || {})
|
|
70
|
+
: input;
|
|
71
|
+
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
if (data.purpose) parts.push(section("Purpose", data.purpose));
|
|
74
|
+
if (data.scope) parts.push(section("Scope", data.scope));
|
|
75
|
+
if (data.applicability) parts.push(section("Applicability", data.applicability));
|
|
76
|
+
|
|
77
|
+
const prereq = data.prerequisites;
|
|
78
|
+
if (prereq) {
|
|
79
|
+
const pieces: string[] = [];
|
|
80
|
+
const cond = (prereq.conditions || []).map((c) => c?.text).filter(Boolean) as string[];
|
|
81
|
+
if (cond.length) pieces.push(`**Conditions:**\n${bullet(cond)}`);
|
|
82
|
+
if (prereq.required_tools?.length) pieces.push(`**Required tools:**\n${bullet(prereq.required_tools)}`);
|
|
83
|
+
if (prereq.required_permissions?.length) pieces.push(`**Required permissions:**\n${bullet(prereq.required_permissions)}`);
|
|
84
|
+
if (prereq.safety_warnings?.length) pieces.push(`**Safety warnings:**\n${bullet(prereq.safety_warnings)}`);
|
|
85
|
+
if (pieces.length) parts.push(section("Prerequisites", pieces.join("\n\n")));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (data.procedure_steps?.length) {
|
|
89
|
+
const steps = data.procedure_steps.map((step, i) => {
|
|
90
|
+
const lines: string[] = [`${i + 1}. **${step.name || `Step ${i + 1}`}**`];
|
|
91
|
+
if (step.action) lines.push(` - Action: ${step.action}`);
|
|
92
|
+
if (step.owner) lines.push(` - Owner: ${step.owner}`);
|
|
93
|
+
if (step.output) lines.push(` - Output: ${step.output}`);
|
|
94
|
+
if (step.decision_logic) lines.push(` - Decision logic: ${step.decision_logic}`);
|
|
95
|
+
if (step.failure_state) lines.push(` - Failure state: ${step.failure_state}`);
|
|
96
|
+
if (step.fallback_action) lines.push(` - Fallback: ${step.fallback_action}`);
|
|
97
|
+
if (step.estimated_duration) lines.push(` - ETA: ${step.estimated_duration}`);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}).join("\n");
|
|
100
|
+
parts.push(section("Procedure", steps));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (data.expected_outputs?.length) parts.push(section("Expected Outputs", bullet(data.expected_outputs)));
|
|
104
|
+
if (data.acceptance_criteria?.length) {
|
|
105
|
+
parts.push(section("Acceptance Criteria", bullet(data.acceptance_criteria.map((c) => c?.text || ""))));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (data.escalation_rules?.length) {
|
|
109
|
+
const rules = data.escalation_rules
|
|
110
|
+
.filter((r) => r.trigger || r.escalateTo)
|
|
111
|
+
.map((r) => `- [${r.priority || "P2"}] ${r.trigger || "(unspecified trigger)"} → ${r.escalateTo || "(unspecified)"}`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
if (rules) parts.push(section("Escalation", rules));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (data.rollback_procedure) parts.push(section("Rollback", data.rollback_procedure));
|
|
117
|
+
if (data.notes) parts.push(section("Notes", data.notes));
|
|
118
|
+
|
|
119
|
+
return joinSections(parts);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renderFrameworkMarkdown(input: string | FrameworkData | null | undefined): string {
|
|
123
|
+
if (input == null) return "";
|
|
124
|
+
const data: FrameworkData = typeof input === "string"
|
|
125
|
+
? (safeParse<FrameworkData>(input) || {})
|
|
126
|
+
: input;
|
|
127
|
+
|
|
128
|
+
const parts: string[] = [];
|
|
129
|
+
if (data.mission) parts.push(section("Mission", data.mission));
|
|
130
|
+
if (data.vision) parts.push(section("Vision", data.vision));
|
|
131
|
+
if (data.core_principles?.length) parts.push(section("Core Principles", bullet(data.core_principles)));
|
|
132
|
+
|
|
133
|
+
const authParts: string[] = [];
|
|
134
|
+
if (data.decision_authority) authParts.push(`**Decision authority:** ${data.decision_authority}`);
|
|
135
|
+
if (data.budget_constraints) authParts.push(`**Budget constraints:** ${data.budget_constraints}`);
|
|
136
|
+
if (data.resource_limits) authParts.push(`**Resource limits:** ${data.resource_limits}`);
|
|
137
|
+
if (data.tool_stack?.length) authParts.push(`**Tool stack:**\n${bullet(data.tool_stack)}`);
|
|
138
|
+
if (authParts.length) parts.push(section("Authority & Constraints", authParts.join("\n\n")));
|
|
139
|
+
|
|
140
|
+
if (data.strategic_objectives?.length) {
|
|
141
|
+
const rows = data.strategic_objectives
|
|
142
|
+
.filter((o) => o.objective || o.target)
|
|
143
|
+
.map((o) => {
|
|
144
|
+
const bits = [
|
|
145
|
+
o.objective ? `**${o.objective}**` : "",
|
|
146
|
+
o.target ? `target: ${o.target}` : "",
|
|
147
|
+
o.measurement ? `measure: ${o.measurement}` : "",
|
|
148
|
+
o.frequency ? `cadence: ${o.frequency}` : "",
|
|
149
|
+
].filter(Boolean);
|
|
150
|
+
return `- ${bits.join(" · ")}`;
|
|
151
|
+
})
|
|
152
|
+
.join("\n");
|
|
153
|
+
if (rows) parts.push(section("Strategic Objectives (KPIs)", rows));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const riskParts: string[] = [];
|
|
157
|
+
if (data.risk_appetite) riskParts.push(`**Risk appetite:** ${data.risk_appetite}`);
|
|
158
|
+
if (data.compliance_requirements?.length) riskParts.push(`**Compliance:**\n${bullet(data.compliance_requirements)}`);
|
|
159
|
+
if (riskParts.length) parts.push(section("Risk & Compliance", riskParts.join("\n\n")));
|
|
160
|
+
|
|
161
|
+
if (data.escalation_matrix?.length) {
|
|
162
|
+
const rows = data.escalation_matrix
|
|
163
|
+
.filter((e) => e.trigger || e.escalate_to)
|
|
164
|
+
.map((e) => {
|
|
165
|
+
const sla = e.response_sla ? ` (SLA ${e.response_sla})` : "";
|
|
166
|
+
return `- [${e.priority || "P2"}] ${e.trigger || "(unspecified)"} → ${e.escalate_to || "(unspecified)"}${sla}`;
|
|
167
|
+
})
|
|
168
|
+
.join("\n");
|
|
169
|
+
if (rows) parts.push(section("Escalation Matrix", rows));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const teamParts: string[] = [];
|
|
173
|
+
if (data.team_composition) teamParts.push(`**Team:** ${data.team_composition}`);
|
|
174
|
+
if (data.integration_points?.length) teamParts.push(`**Integration points:**\n${bullet(data.integration_points)}`);
|
|
175
|
+
if (teamParts.length) parts.push(section("Team & Integrations", teamParts.join("\n\n")));
|
|
176
|
+
|
|
177
|
+
if (data.review_frequency || data.next_review) {
|
|
178
|
+
const lines = [
|
|
179
|
+
data.review_frequency ? `**Review cadence:** ${data.review_frequency}` : "",
|
|
180
|
+
data.last_reviewed ? `**Last reviewed:** ${data.last_reviewed}` : "",
|
|
181
|
+
data.next_review ? `**Next review:** ${data.next_review}` : "",
|
|
182
|
+
].filter(Boolean);
|
|
183
|
+
if (lines.length) parts.push(section("Review Cycle", lines.join("\n")));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (data.notes) parts.push(section("Notes", data.notes));
|
|
187
|
+
|
|
188
|
+
return joinSections(parts);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function renderAttachmentBlock(args: {
|
|
192
|
+
sops?: Array<{ title: string; content: string }>;
|
|
193
|
+
frameworks?: Array<{ title: string; content: string }>;
|
|
194
|
+
}): string {
|
|
195
|
+
const blocks: string[] = [];
|
|
196
|
+
|
|
197
|
+
if (args.frameworks?.length) {
|
|
198
|
+
const sections = args.frameworks.map((fw) => {
|
|
199
|
+
const md = renderFrameworkMarkdown(fw.content);
|
|
200
|
+
if (!md) return `## ${fw.title}\n_(empty)_`;
|
|
201
|
+
return `## ${fw.title}\n${md}`;
|
|
202
|
+
}).join("\n\n");
|
|
203
|
+
blocks.push(`[FRAMEWORKS — ATTACHED]\nThese frameworks are active for this run. Orient strategic decisions toward them.\n\n${sections}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (args.sops?.length) {
|
|
207
|
+
const sections = args.sops.map((sop) => {
|
|
208
|
+
const md = renderSopMarkdown(sop.content);
|
|
209
|
+
if (!md) return `## ${sop.title}\n_(empty)_`;
|
|
210
|
+
return `## ${sop.title}\n${md}`;
|
|
211
|
+
}).join("\n\n");
|
|
212
|
+
blocks.push(`[SOPS — ATTACHED]\nThese SOPs are active for this run. Follow the procedure steps and escalation rules.\n\n${sections}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return blocks.join("\n\n");
|
|
216
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
14
14
|
import type { OfiereConfig } from "./types.js";
|
|
15
15
|
import { resolveAgentId } from "./agent-resolver.js";
|
|
16
|
+
import {
|
|
17
|
+
handleProposeAttach,
|
|
18
|
+
handleCommitAttach,
|
|
19
|
+
registerAttachmentContextHook,
|
|
20
|
+
} from "./attachments.js";
|
|
16
21
|
|
|
17
22
|
// ─── Tool result shape (matches OpenClaw SDK) ────────────────────────────────
|
|
18
23
|
|
|
@@ -4843,7 +4848,10 @@ function registerSOPOps(
|
|
|
4843
4848
|
`- "update": Modify SOP. Required: sop_id. Optional: title, sop_data, status, department\n` +
|
|
4844
4849
|
`- "delete": Remove SOP. Required: sop_id\n` +
|
|
4845
4850
|
`- "list_subagents": List subagents for a chief. Required: chief_agent_id\n` +
|
|
4846
|
-
`- "apply_template": Create SOP from template. Required: agent_id, template_id. Optional: title, department\n
|
|
4851
|
+
`- "apply_template": Create SOP from template. Required: agent_id, template_id. Optional: title, department\n` +
|
|
4852
|
+
`- "propose_attach": Propose attaching SOPs to a run target (conversation/task/scheduler_event). Returns token cost + confirmation_token. Required: target_kind, target_id, doc_ids[]. The user MUST be asked to approve before commit.\n` +
|
|
4853
|
+
`- "commit_attach": Commit a proposed attachment. Required: target_kind, target_id, doc_ids[], confirmation_token (from propose_attach). Only call AFTER user approves the token cost.\n` +
|
|
4854
|
+
`Tier rule: SOPs only attach to targets whose assigned agent is Staff. Use OFIERE_FRAMEWORK_OPS for c-suite targets.\n\n` +
|
|
4847
4855
|
`sop_data structure (JSON object):\n` +
|
|
4848
4856
|
`{\n` +
|
|
4849
4857
|
` title: string,\n` +
|
|
@@ -4863,11 +4871,15 @@ function registerSOPOps(
|
|
|
4863
4871
|
type: "object",
|
|
4864
4872
|
required: ["action"],
|
|
4865
4873
|
properties: {
|
|
4866
|
-
action: { type: "string", enum: ["list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template"], description: "Valid actions: list_templates, create, list, get, update, delete, list_subagents, apply_template" },
|
|
4874
|
+
action: { type: "string", enum: ["list_templates", "create", "list", "get", "update", "delete", "list_subagents", "apply_template", "propose_attach", "commit_attach"], description: "Valid actions: list_templates, create, list, get, update, delete, list_subagents, apply_template, propose_attach, commit_attach" },
|
|
4867
4875
|
sop_id: { type: "string", description: "SOP ID (required for get, update, delete)" },
|
|
4868
4876
|
agent_id: { type: "string", description: "Agent ID (required for create, list filter, apply_template)" },
|
|
4869
4877
|
chief_agent_id: { type: "string", description: "Chief agent ID (required for list_subagents)" },
|
|
4870
4878
|
template_id: { type: "string", description: "Template ID (required for apply_template)" },
|
|
4879
|
+
target_kind: { type: "string", enum: ["conversation", "task", "scheduler_event"], description: "Run target kind (for propose_attach/commit_attach)" },
|
|
4880
|
+
target_id: { type: "string", description: "Run target id (for propose_attach/commit_attach)" },
|
|
4881
|
+
doc_ids: { type: "array", items: { type: "string" }, description: "SOP ids to attach (for propose_attach/commit_attach)" },
|
|
4882
|
+
confirmation_token: { type: "string", description: "Token returned by propose_attach. Required for commit_attach." },
|
|
4871
4883
|
title: { type: "string", description: "SOP title" },
|
|
4872
4884
|
department: { type: "string", description: "Department name (e.g. Marketing & Revenue)" },
|
|
4873
4885
|
status: { type: "string", enum: ["draft", "active", "archived", "under_review"], description: "SOP status" },
|
|
@@ -4921,7 +4933,9 @@ function registerSOPOps(
|
|
|
4921
4933
|
case "delete": return handleSOPDelete(supabase, userId, params);
|
|
4922
4934
|
case "list_subagents": return handleSOPListSubagents(supabase, userId, params);
|
|
4923
4935
|
case "apply_template": return handleSOPApplyTemplate(supabase, userId, resolveAgent, params);
|
|
4924
|
-
|
|
4936
|
+
case "propose_attach": return handleProposeAttach({ supabase, userId, docKind: "sop", params });
|
|
4937
|
+
case "commit_attach": return handleCommitAttach({ supabase, userId, docKind: "sop", params });
|
|
4938
|
+
default: return err(`Unknown action "${action}". Valid: list_templates, create, list, get, update, delete, list_subagents, apply_template, propose_attach, commit_attach`);
|
|
4925
4939
|
}
|
|
4926
4940
|
},
|
|
4927
4941
|
});
|
|
@@ -5237,7 +5251,10 @@ function registerFrameworkOps(
|
|
|
5237
5251
|
`- "list": List frameworks. Optional: agent_id to filter\n` +
|
|
5238
5252
|
`- "get": Get full framework details. Required: framework_id\n` +
|
|
5239
5253
|
`- "update": Modify framework. Required: framework_id. Optional: title, content, status, department, category\n` +
|
|
5240
|
-
`- "delete": Remove framework. Required: framework_id\n
|
|
5254
|
+
`- "delete": Remove framework. Required: framework_id\n` +
|
|
5255
|
+
`- "propose_attach": Propose attaching Frameworks to a run target. Returns token cost + confirmation_token. Required: target_kind, target_id, doc_ids[]. The user MUST be asked to approve before commit.\n` +
|
|
5256
|
+
`- "commit_attach": Commit a proposed attachment. Required: target_kind, target_id, doc_ids[], confirmation_token (from propose_attach). Only call AFTER user approves the token cost.\n` +
|
|
5257
|
+
`Tier rule: Frameworks only attach to targets whose assigned agent is C-Suite. Use OFIERE_SOP_OPS for staff targets.\n\n` +
|
|
5241
5258
|
`content: A JSON string containing the full framework body. Example:\n` +
|
|
5242
5259
|
`"{\\"mission\\":\\"Drive brand growth\\",\\"vision\\":\\"Market leader by 2027\\",\\"core_principles\\":[\\"Data-driven\\",\\"Customer-first\\"],` +
|
|
5243
5260
|
`\\"decision_authority\\":\\"CMO approves >$10k spend\\",\\"budget_constraints\\":\\"$50k monthly cap\\",` +
|
|
@@ -5254,7 +5271,7 @@ function registerFrameworkOps(
|
|
|
5254
5271
|
type: "object",
|
|
5255
5272
|
required: ["action"],
|
|
5256
5273
|
properties: {
|
|
5257
|
-
action: { type: "string", enum: ["create", "list", "get", "update", "delete"], description: "Framework action" },
|
|
5274
|
+
action: { type: "string", enum: ["create", "list", "get", "update", "delete", "propose_attach", "commit_attach"], description: "Framework action" },
|
|
5258
5275
|
framework_id: { type: "string", description: "Framework ID (required for get, update, delete)" },
|
|
5259
5276
|
agent_id: { type: "string", description: "Agent ID (required for create, optional filter for list)" },
|
|
5260
5277
|
title: { type: "string", description: "Framework title" },
|
|
@@ -5262,6 +5279,10 @@ function registerFrameworkOps(
|
|
|
5262
5279
|
department: { type: "string" },
|
|
5263
5280
|
category: { type: "string" },
|
|
5264
5281
|
status: { type: "string", enum: ["draft", "active", "under_review", "archived"] },
|
|
5282
|
+
target_kind: { type: "string", enum: ["conversation", "task", "scheduler_event"], description: "Run target kind (for propose_attach/commit_attach)" },
|
|
5283
|
+
target_id: { type: "string", description: "Run target id (for propose_attach/commit_attach)" },
|
|
5284
|
+
doc_ids: { type: "array", items: { type: "string" }, description: "Framework ids to attach (for propose_attach/commit_attach)" },
|
|
5285
|
+
confirmation_token: { type: "string", description: "Token returned by propose_attach. Required for commit_attach." },
|
|
5265
5286
|
},
|
|
5266
5287
|
},
|
|
5267
5288
|
async execute(_id: string, params: Record<string, unknown>) {
|
|
@@ -5272,7 +5293,9 @@ function registerFrameworkOps(
|
|
|
5272
5293
|
case "get": return handleFWGet(supabase, userId, params);
|
|
5273
5294
|
case "update": return handleFWUpdate(supabase, userId, params);
|
|
5274
5295
|
case "delete": return handleFWDelete(supabase, userId, params);
|
|
5275
|
-
|
|
5296
|
+
case "propose_attach": return handleProposeAttach({ supabase, userId, docKind: "framework", params });
|
|
5297
|
+
case "commit_attach": return handleCommitAttach({ supabase, userId, docKind: "framework", params });
|
|
5298
|
+
default: return err(`Unknown action "${action}". Valid: create, list, get, update, delete, propose_attach, commit_attach`);
|
|
5276
5299
|
}
|
|
5277
5300
|
},
|
|
5278
5301
|
});
|
|
@@ -6127,6 +6150,9 @@ export function registerTools(
|
|
|
6127
6150
|
// ── Register talent context hook ──
|
|
6128
6151
|
registerTalentContextHook(api, supabase, userId, fallbackAgentId);
|
|
6129
6152
|
|
|
6153
|
+
// ── Register attachment context hook (SOPs/Frameworks injection) ──
|
|
6154
|
+
registerAttachmentContextHook({ api, supabase, userId, fallbackAgentId, resolveAgent });
|
|
6155
|
+
|
|
6130
6156
|
// ── Register agent_end hook for server-side brain extraction ──
|
|
6131
6157
|
registerBrainExtractionHook(api, supabase, userId, fallbackAgentId);
|
|
6132
6158
|
|