clawaxis 1.0.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/CLAWAXIS.md +954 -0
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/index.ts +53 -0
- package/openclaw.plugin.json +86 -0
- package/package.json +33 -0
- package/src/channel.ts +78 -0
- package/src/config.ts +82 -0
- package/src/governance/hooks.ts +92 -0
- package/src/governance/logging.ts +58 -0
- package/src/governance/style-injection.ts +114 -0
- package/src/governance/validation.ts +206 -0
- package/src/message-handler.ts +212 -0
- package/src/runtime.ts +91 -0
- package/src/sync/documents.ts +127 -0
- package/src/sync/routines.ts +56 -0
- package/src/tools/content.ts +64 -0
- package/src/tools/helpers.ts +40 -0
- package/src/tools/index.ts +16 -0
- package/src/tools/logging.ts +33 -0
- package/src/tools/media.ts +21 -0
- package/src/tools/memory.ts +78 -0
- package/src/tools/routines.ts +54 -0
- package/src/tools/sync.ts +36 -0
- package/src/types.ts +64 -0
- package/src/utils.ts +161 -0
- package/src/websocket.ts +294 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { LOG_PREFIX, loadConfig } from "./config.js";
|
|
2
|
+
import { setUtilsConfig, relayFetch } from "./utils.js";
|
|
3
|
+
import { createWebSocketService } from "./websocket.js";
|
|
4
|
+
|
|
5
|
+
export const clawaxisChannel = {
|
|
6
|
+
id: "clawaxis",
|
|
7
|
+
meta: {
|
|
8
|
+
id: "clawaxis",
|
|
9
|
+
label: "ClawAxis",
|
|
10
|
+
selectionLabel: "ClawAxis (Mobile)",
|
|
11
|
+
blurb: "Control your OpenClaw agent from the ClawAxis mobile app.",
|
|
12
|
+
aliases: ["sc"],
|
|
13
|
+
},
|
|
14
|
+
capabilities: {
|
|
15
|
+
chatTypes: ["direct"],
|
|
16
|
+
},
|
|
17
|
+
config: {
|
|
18
|
+
listAccountIds: (cfg: any) =>
|
|
19
|
+
Object.keys(cfg.channels?.clawaxis?.accounts ?? {}),
|
|
20
|
+
resolveAccount: (cfg: any, accountId: string) =>
|
|
21
|
+
cfg.channels?.clawaxis?.accounts?.[accountId ?? "default"] ?? {
|
|
22
|
+
accountId,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
outbound: {
|
|
26
|
+
deliveryMode: "direct",
|
|
27
|
+
sendText: async () => ({ ok: true }),
|
|
28
|
+
sendMedia: async (ctx: any) => {
|
|
29
|
+
const result = await relayFetch("POST", "message", {
|
|
30
|
+
content: ctx.caption || "[media]",
|
|
31
|
+
message_type: "image",
|
|
32
|
+
});
|
|
33
|
+
return { ok: !!result };
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
gateway: {
|
|
38
|
+
/**
|
|
39
|
+
* OpenClaw calls startAccount when the channel activates.
|
|
40
|
+
* This is the correct lifecycle hook for channel plugins —
|
|
41
|
+
* it replaces manual api.registerService() + wsService.start().
|
|
42
|
+
*
|
|
43
|
+
* ctx.account.config is pre-scoped to the channel config
|
|
44
|
+
* (relayUrl/agentToken already at top level).
|
|
45
|
+
*
|
|
46
|
+
* Returns a cleanup function that OpenClaw calls on shutdown.
|
|
47
|
+
*/
|
|
48
|
+
startAccount: async (ctx: any) => {
|
|
49
|
+
const accountId = ctx.account?.accountId || "default";
|
|
50
|
+
console.log(`${LOG_PREFIX} gateway.startAccount called for account: ${accountId}`);
|
|
51
|
+
|
|
52
|
+
const accountConfig = ctx.account?.config || ctx.config;
|
|
53
|
+
const config = loadConfig(accountConfig);
|
|
54
|
+
|
|
55
|
+
if (!config) {
|
|
56
|
+
console.warn(`${LOG_PREFIX} No valid config for account ${accountId}, skipping WebSocket start`);
|
|
57
|
+
return () => {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setUtilsConfig(config);
|
|
61
|
+
|
|
62
|
+
console.log(`${LOG_PREFIX} \u2713 Config loaded for account ${accountId}`);
|
|
63
|
+
if (config.mediaDomain) console.log(`${LOG_PREFIX} \u2022 Media domain: ${config.mediaDomain}`);
|
|
64
|
+
if (config.agentName) console.log(`${LOG_PREFIX} \u2022 Agent name: ${config.agentName}`);
|
|
65
|
+
|
|
66
|
+
const wsService = createWebSocketService(config);
|
|
67
|
+
await wsService.start();
|
|
68
|
+
|
|
69
|
+
console.log(`${LOG_PREFIX} \u2713 Account ${accountId} started (WebSocket active)`);
|
|
70
|
+
|
|
71
|
+
return async () => {
|
|
72
|
+
console.log(`${LOG_PREFIX} Stopping account ${accountId}...`);
|
|
73
|
+
await wsService.stop();
|
|
74
|
+
console.log(`${LOG_PREFIX} \u2713 Account ${accountId} stopped`);
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { GovernanceConfig, ClawAxisConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const HEARTBEAT_MS = 60000;
|
|
4
|
+
export const PING_MS = 30000;
|
|
5
|
+
export const REAUTH_BUFFER_MS = 5000;
|
|
6
|
+
export const MAX_RECONNECT_DELAY = 60000;
|
|
7
|
+
export const LOG_PREFIX = "\u{1F99E}";
|
|
8
|
+
export const PLUGIN_VERSION = "2.0.0";
|
|
9
|
+
|
|
10
|
+
// Supabase anon key (public by design — RLS enforces security, not key secrecy)
|
|
11
|
+
export const ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxd3B3eXB5emNibWRlYWpsa3p0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA2NDUxOTIsImV4cCI6MjA4NjIyMTE5Mn0.Dc_gve3L8tMXPMqWZQYq-gxCCdPnm7Z5W6A9fg0pLis";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_GOVERNANCE: GovernanceConfig = {
|
|
14
|
+
imageTools: [],
|
|
15
|
+
contentTools: [
|
|
16
|
+
"clawaxis_post_content",
|
|
17
|
+
"clawaxis_update_content"
|
|
18
|
+
],
|
|
19
|
+
publishingTools: [
|
|
20
|
+
"clawaxis_post_content",
|
|
21
|
+
"clawaxis_update_content"
|
|
22
|
+
],
|
|
23
|
+
memorySearch: {
|
|
24
|
+
contentQuery: "{content_type} style guide writing tone brand voice",
|
|
25
|
+
imageQuery: "image style guide brand visual aesthetic colors",
|
|
26
|
+
maxResults: 5,
|
|
27
|
+
minScore: 0.65,
|
|
28
|
+
cacheDurationMs: 300000
|
|
29
|
+
},
|
|
30
|
+
validation: {
|
|
31
|
+
requireCTA: false,
|
|
32
|
+
requirePermanentImages: false,
|
|
33
|
+
requireValidAuthor: false,
|
|
34
|
+
allowedContentTypes: ["twitter", "instagram", "linkedin", "facebook", "tiktok", "youtube", "blog", "report", "email", "ad", "website", "document", "other"]
|
|
35
|
+
},
|
|
36
|
+
logging: {
|
|
37
|
+
enabled: true,
|
|
38
|
+
logAllTools: false,
|
|
39
|
+
logGovernanceIssues: true,
|
|
40
|
+
debounceMs: 100
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load and merge config from OpenClaw's config hierarchy.
|
|
46
|
+
*
|
|
47
|
+
* Supports three paths:
|
|
48
|
+
* 1. Pre-scoped config (from gateway.startAccount ctx.account.config) —
|
|
49
|
+
* already has relayUrl/agentToken at top level
|
|
50
|
+
* 2. channels.clawaxis — primary location in openclaw.json
|
|
51
|
+
* 3. plugins.entries.clawaxis-plugin.config — backward compat
|
|
52
|
+
*/
|
|
53
|
+
export function loadConfig(apiConfig: any): ClawAxisConfig | null {
|
|
54
|
+
let cfg: any;
|
|
55
|
+
|
|
56
|
+
if (apiConfig?.relayUrl && apiConfig?.agentToken) {
|
|
57
|
+
cfg = apiConfig;
|
|
58
|
+
} else {
|
|
59
|
+
cfg = apiConfig?.channels?.clawaxis
|
|
60
|
+
?? apiConfig?.plugins?.entries?.["clawaxis-plugin"]?.config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!cfg) return null;
|
|
64
|
+
if (!cfg.relayUrl || !cfg.agentToken) return null;
|
|
65
|
+
|
|
66
|
+
const governance: GovernanceConfig = {
|
|
67
|
+
imageTools: cfg.governance?.imageTools || DEFAULT_GOVERNANCE.imageTools,
|
|
68
|
+
contentTools: cfg.governance?.contentTools || DEFAULT_GOVERNANCE.contentTools,
|
|
69
|
+
publishingTools: cfg.governance?.publishingTools || DEFAULT_GOVERNANCE.publishingTools,
|
|
70
|
+
memorySearch: { ...DEFAULT_GOVERNANCE.memorySearch, ...(cfg.governance?.memorySearch || {}) },
|
|
71
|
+
validation: { ...DEFAULT_GOVERNANCE.validation, ...(cfg.governance?.validation || {}) },
|
|
72
|
+
logging: { ...DEFAULT_GOVERNANCE.logging, ...(cfg.governance?.logging || {}) }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
relayUrl: cfg.relayUrl,
|
|
77
|
+
agentToken: cfg.agentToken,
|
|
78
|
+
mediaDomain: cfg.mediaDomain || null,
|
|
79
|
+
agentName: apiConfig?.agents?.defaults?.name || cfg.agentName || null,
|
|
80
|
+
governance,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { LOG_PREFIX } from "../config.js";
|
|
2
|
+
import { getFlushTimeout, setFlushTimeout } from "../runtime.js";
|
|
3
|
+
import { flushLogQueue } from "../utils.js";
|
|
4
|
+
import { logToolCall } from "./logging.js";
|
|
5
|
+
import { injectStyleDocs } from "./style-injection.js";
|
|
6
|
+
import { enforceGovernance } from "./validation.js";
|
|
7
|
+
import type { HookContext, ClawAxisConfig } from "../types.js";
|
|
8
|
+
|
|
9
|
+
const ENHANCEMENT_TIMEOUT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
12
|
+
return Promise.race([
|
|
13
|
+
promise,
|
|
14
|
+
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms)),
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerGovernanceHooks(api: any, config: ClawAxisConfig) {
|
|
19
|
+
const governance = config.governance;
|
|
20
|
+
|
|
21
|
+
if (typeof api.registerHook !== "function") {
|
|
22
|
+
console.warn(`${LOG_PREFIX} api.registerHook not available, skipping governance hooks`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log(`${LOG_PREFIX} \uD83D\uDEE1\uFE0F Registering governance hooks...`);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
api.registerHook("before_tool_call", {
|
|
30
|
+
name: "clawaxis-governance-before",
|
|
31
|
+
handler: async (context: HookContext) => {
|
|
32
|
+
try {
|
|
33
|
+
context.api = context.api || api;
|
|
34
|
+
|
|
35
|
+
// Layer 1 (logging) and Layer 2 (style injection) are independent — run in parallel.
|
|
36
|
+
// Layer 3 (validation) depends on injection results, so it runs after.
|
|
37
|
+
// Entire enhancement is capped at ENHANCEMENT_TIMEOUT_MS to avoid stalling the agent.
|
|
38
|
+
const enhanced = await withTimeout(
|
|
39
|
+
(async () => {
|
|
40
|
+
await Promise.all([
|
|
41
|
+
logToolCall(context, "before", governance),
|
|
42
|
+
injectStyleDocs(context, governance),
|
|
43
|
+
]);
|
|
44
|
+
await enforceGovernance(context, governance, config.agentName, config.mediaDomain);
|
|
45
|
+
return context;
|
|
46
|
+
})(),
|
|
47
|
+
ENHANCEMENT_TIMEOUT_MS,
|
|
48
|
+
context,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return enhanced;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof Error && err.message.includes("[ClawAxis Governance]")) {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
console.error(`${LOG_PREFIX} before_tool_call hook failed:`, err);
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
api.registerHook("after_tool_call", {
|
|
63
|
+
name: "clawaxis-governance-after",
|
|
64
|
+
handler: async (context: HookContext) => {
|
|
65
|
+
try {
|
|
66
|
+
// Fire-and-forget: logging should never block the agent turn
|
|
67
|
+
logToolCall(context, "after", governance).catch((err) => {
|
|
68
|
+
console.error(`${LOG_PREFIX} after_tool_call log failed:`, err);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const ft = getFlushTimeout();
|
|
72
|
+
if (ft) {
|
|
73
|
+
clearTimeout(ft);
|
|
74
|
+
setFlushTimeout(null);
|
|
75
|
+
flushLogQueue().catch((err) => {
|
|
76
|
+
console.error(`${LOG_PREFIX} Log flush failed:`, err);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return context;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`${LOG_PREFIX} after_tool_call hook failed:`, err);
|
|
83
|
+
return context;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
console.log(`${LOG_PREFIX} \u2713 Governance hooks registered (2 hooks)`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`${LOG_PREFIX} Failed to register governance hooks:`, err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { GovernanceConfig, HookContext } from "../types.js";
|
|
2
|
+
import { relayLog } from "../utils.js";
|
|
3
|
+
|
|
4
|
+
function isImageTool(toolName: string, governance: GovernanceConfig): boolean {
|
|
5
|
+
const lower = toolName.toLowerCase();
|
|
6
|
+
return governance.imageTools.some(pattern =>
|
|
7
|
+
lower.includes(pattern.toLowerCase())
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isContentTool(toolName: string, governance: GovernanceConfig): boolean {
|
|
12
|
+
return governance.contentTools.includes(toolName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isPublishingTool(toolName: string, governance: GovernanceConfig): boolean {
|
|
16
|
+
return governance.publishingTools.includes(toolName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function logToolCall(
|
|
20
|
+
context: HookContext,
|
|
21
|
+
phase: "before" | "after",
|
|
22
|
+
governance: GovernanceConfig,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const { toolName, durationMs, success, error } = context;
|
|
25
|
+
|
|
26
|
+
const isClawaxisTool = toolName.startsWith("clawaxis_");
|
|
27
|
+
const isGovernanceTool = isImageTool(toolName, governance)
|
|
28
|
+
|| isContentTool(toolName, governance)
|
|
29
|
+
|| isPublishingTool(toolName, governance);
|
|
30
|
+
|
|
31
|
+
if (!governance.logging.logAllTools && !isClawaxisTool && !isGovernanceTool) return;
|
|
32
|
+
|
|
33
|
+
if (phase === "before") {
|
|
34
|
+
await relayLog(
|
|
35
|
+
"tool_call",
|
|
36
|
+
`Calling ${toolName}`,
|
|
37
|
+
"info",
|
|
38
|
+
{ toolName, phase: "start" }
|
|
39
|
+
);
|
|
40
|
+
} else {
|
|
41
|
+
const level = success === false ? "error" : "success";
|
|
42
|
+
const duration = durationMs ? ` (${durationMs}ms)` : "";
|
|
43
|
+
const status = success === false ? "failed" : "completed";
|
|
44
|
+
|
|
45
|
+
await relayLog(
|
|
46
|
+
"tool_call",
|
|
47
|
+
`${toolName} ${status}${duration}`,
|
|
48
|
+
level,
|
|
49
|
+
{
|
|
50
|
+
toolName,
|
|
51
|
+
phase: "end",
|
|
52
|
+
durationMs,
|
|
53
|
+
success,
|
|
54
|
+
error: error ? error.message : undefined
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { LOG_PREFIX } from "../config.js";
|
|
2
|
+
import { getMemoryCache } from "../runtime.js";
|
|
3
|
+
import { relayLog } from "../utils.js";
|
|
4
|
+
import type { GovernanceConfig, HookContext } from "../types.js";
|
|
5
|
+
|
|
6
|
+
function isContentTool(toolName: string, governance: GovernanceConfig): boolean {
|
|
7
|
+
return governance.contentTools.includes(toolName);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isImageTool(toolName: string, governance: GovernanceConfig): boolean {
|
|
11
|
+
const lower = toolName.toLowerCase();
|
|
12
|
+
return governance.imageTools.some(pattern =>
|
|
13
|
+
lower.includes(pattern.toLowerCase())
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function searchMemory(
|
|
18
|
+
context: HookContext,
|
|
19
|
+
query: string,
|
|
20
|
+
governance: GovernanceConfig,
|
|
21
|
+
): Promise<any[]> {
|
|
22
|
+
const session = context.sessionKey || context.agentId || "main";
|
|
23
|
+
const cacheKey = `${session}:${query}`;
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const cache = getMemoryCache();
|
|
26
|
+
|
|
27
|
+
const cached = cache.get(cacheKey);
|
|
28
|
+
if (cached && (now - cached.timestamp) < governance.memorySearch.cacheDurationMs) {
|
|
29
|
+
return cached.snippets;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const result = await context.api?.gateway?.call("memory.search", {
|
|
34
|
+
agentId: context.agentId || "main",
|
|
35
|
+
query,
|
|
36
|
+
maxResults: governance.memorySearch.maxResults,
|
|
37
|
+
minScore: governance.memorySearch.minScore,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const snippets = result?.snippets || [];
|
|
41
|
+
cache.set(cacheKey, { snippets, timestamp: now });
|
|
42
|
+
return snippets;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`${LOG_PREFIX} Memory search failed:`, err);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function injectStyleDocs(
|
|
50
|
+
context: HookContext,
|
|
51
|
+
governance: GovernanceConfig,
|
|
52
|
+
): Promise<HookContext> {
|
|
53
|
+
const { toolName, params } = context;
|
|
54
|
+
|
|
55
|
+
const isContent = isContentTool(toolName, governance);
|
|
56
|
+
const isImage = isImageTool(toolName, governance);
|
|
57
|
+
|
|
58
|
+
if (!isContent && !isImage) return context;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
let searchQuery: string;
|
|
62
|
+
if (isImage) {
|
|
63
|
+
searchQuery = governance.memorySearch.imageQuery;
|
|
64
|
+
} else {
|
|
65
|
+
const contentType = params?.content_type || "blog";
|
|
66
|
+
searchQuery = governance.memorySearch.contentQuery
|
|
67
|
+
.replace("{content_type}", contentType);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const snippets = await searchMemory(context, searchQuery, governance);
|
|
71
|
+
if (snippets.length === 0) return context;
|
|
72
|
+
|
|
73
|
+
const docsText = snippets
|
|
74
|
+
.map((s: any) => s.content)
|
|
75
|
+
.join("\n\n---\n\n");
|
|
76
|
+
|
|
77
|
+
if (isImage && params) {
|
|
78
|
+
const promptKeys = ["instructions", "prompt", "text", "description", "query"];
|
|
79
|
+
const promptKey = promptKeys.find(key => params[key]);
|
|
80
|
+
if (promptKey && params[promptKey]) {
|
|
81
|
+
const originalPrompt = params[promptKey];
|
|
82
|
+
params[promptKey] = `${originalPrompt}\n\nStyle reference from brand guidelines:\n${docsText}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isContent) {
|
|
87
|
+
context._injectedDocs = docsText;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await relayLog(
|
|
91
|
+
"content_generate",
|
|
92
|
+
`Injected ${snippets.length} reference doc chunk(s) for ${toolName}`,
|
|
93
|
+
"info",
|
|
94
|
+
{
|
|
95
|
+
toolName,
|
|
96
|
+
snippetCount: snippets.length,
|
|
97
|
+
docPaths: snippets.map((s: any) => s.path)
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(`${LOG_PREFIX} Style injection failed:`, err);
|
|
102
|
+
|
|
103
|
+
if (governance.logging.logGovernanceIssues) {
|
|
104
|
+
await relayLog(
|
|
105
|
+
"error",
|
|
106
|
+
`Style injection failed for ${toolName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
107
|
+
"warning",
|
|
108
|
+
{ toolName, error: err instanceof Error ? err.message : String(err) }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return context;
|
|
114
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { LOG_PREFIX } from "../config.js";
|
|
2
|
+
import { relayLog, flushLogQueue } from "../utils.js";
|
|
3
|
+
import type { GovernanceConfig, ValidationIssue, HookContext } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export function validateContent(
|
|
6
|
+
params: any,
|
|
7
|
+
governance: GovernanceConfig,
|
|
8
|
+
agentName: string | null,
|
|
9
|
+
mediaDomain: string | null,
|
|
10
|
+
): ValidationIssue[] {
|
|
11
|
+
const issues: ValidationIssue[] = [];
|
|
12
|
+
const body = params?.body || "";
|
|
13
|
+
const contentType = params?.content_type || "blog";
|
|
14
|
+
|
|
15
|
+
// Check 1: Valid content type
|
|
16
|
+
if (!governance.validation.allowedContentTypes.includes(contentType)) {
|
|
17
|
+
issues.push({
|
|
18
|
+
field: "content_type",
|
|
19
|
+
issue: `Invalid content_type '${contentType}'. Valid: ${governance.validation.allowedContentTypes.join(", ")}`,
|
|
20
|
+
severity: "error",
|
|
21
|
+
autofix: () => { params.content_type = "other"; }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check 2: Author attribution
|
|
26
|
+
if (governance.validation.requireValidAuthor && agentName) {
|
|
27
|
+
const agentNameLower = agentName.toLowerCase();
|
|
28
|
+
const bodyLower = body.toLowerCase();
|
|
29
|
+
|
|
30
|
+
const authorPatterns = [
|
|
31
|
+
`written by ${agentNameLower}`,
|
|
32
|
+
`by ${agentNameLower}`,
|
|
33
|
+
`author: ${agentNameLower}`,
|
|
34
|
+
`- ${agentNameLower}`,
|
|
35
|
+
`<meta name="author" content="${agentNameLower}"`,
|
|
36
|
+
`<span class="author">${agentNameLower}`
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
if (authorPatterns.some(pattern => bodyLower.includes(pattern))) {
|
|
40
|
+
issues.push({
|
|
41
|
+
field: "body",
|
|
42
|
+
issue: `Author attribution uses agent name '${agentName}'. Must use the user's name (check user profile in memory).`,
|
|
43
|
+
severity: "error"
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check 3: Image URLs must use configured media domain
|
|
49
|
+
if (governance.validation.requirePermanentImages && mediaDomain) {
|
|
50
|
+
const imgPatterns = [
|
|
51
|
+
/(?:src|content|data-src|poster)=["']([^"']*\.(?:png|jpg|jpeg|gif|webp|svg|avif)[^"']*)["']/gi,
|
|
52
|
+
/url\(["']?([^"')]*\.(?:png|jpg|jpeg|gif|webp|svg|avif)[^"')]*)[")]/gi
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const regex of imgPatterns) {
|
|
56
|
+
let match;
|
|
57
|
+
while ((match = regex.exec(body)) !== null) {
|
|
58
|
+
const imageUrl = match[1];
|
|
59
|
+
if (imageUrl.startsWith("data:") || imageUrl.startsWith("/") || imageUrl.startsWith("./")) continue;
|
|
60
|
+
if (!imageUrl.includes(mediaDomain)) {
|
|
61
|
+
issues.push({
|
|
62
|
+
field: "body",
|
|
63
|
+
issue: `Image URL uses external domain (must upload to ${mediaDomain} first): ${imageUrl.substring(0, 60)}...`,
|
|
64
|
+
severity: "error"
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check 4: og:image meta tag
|
|
72
|
+
if (governance.validation.requirePermanentImages && mediaDomain) {
|
|
73
|
+
const ogMatch = body.match(/<meta\s+property=["']og:image["'][^>]*content=["']([^"']+)["']/i);
|
|
74
|
+
if (ogMatch && ogMatch[1]) {
|
|
75
|
+
const ogImageUrl = ogMatch[1];
|
|
76
|
+
if (!ogImageUrl.startsWith("data:") && !ogImageUrl.includes(mediaDomain)) {
|
|
77
|
+
issues.push({
|
|
78
|
+
field: "og:image",
|
|
79
|
+
issue: `og:image meta tag uses external URL. Should use ${mediaDomain} for permanent storage.`,
|
|
80
|
+
severity: "warning"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check 5: Call-to-action
|
|
87
|
+
if (governance.validation.requireCTA && (contentType === "website" || contentType === "blog")) {
|
|
88
|
+
const ctaPatterns = [
|
|
89
|
+
/\bjoin\s+(us|now|today)\b/i,
|
|
90
|
+
/\bsign\s*up\b/i,
|
|
91
|
+
/\bget\s*started\b/i,
|
|
92
|
+
/\btry\s+(it|now|free|for\s+free)\b/i,
|
|
93
|
+
/\bbeta\b/i,
|
|
94
|
+
/\bsubscribe\b/i,
|
|
95
|
+
/\blearn\s*more\b/i,
|
|
96
|
+
/\bcontact\s+(us|me)\b/i,
|
|
97
|
+
/\bdownload\b/i,
|
|
98
|
+
/\bregister\b/i,
|
|
99
|
+
/\bbook\s+a\s+(call|demo)\b/i,
|
|
100
|
+
/\brequest\s+(a\s+)?(demo|quote)\b/i,
|
|
101
|
+
/<a[^>]+class=["'][^"']*cta[^"']*["']/i,
|
|
102
|
+
/<button[^>]*>/i
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
if (!ctaPatterns.some(pattern => pattern.test(body))) {
|
|
106
|
+
issues.push({
|
|
107
|
+
field: "body",
|
|
108
|
+
issue: "No call-to-action found. Blog and website content should include a CTA (button, link, or action phrase).",
|
|
109
|
+
severity: "warning"
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check 6: Empty content — skip for social platforms
|
|
115
|
+
const socialTypes = ["twitter", "instagram", "facebook", "tiktok", "linkedin", "youtube"];
|
|
116
|
+
if (!socialTypes.includes(contentType) && (!body || body.trim().length < 100)) {
|
|
117
|
+
issues.push({
|
|
118
|
+
field: "body",
|
|
119
|
+
issue: "Content body is too short (minimum 100 characters recommended for long-form content).",
|
|
120
|
+
severity: "warning"
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check 7: Missing title
|
|
125
|
+
if (!params?.title || params.title.trim().length === 0) {
|
|
126
|
+
issues.push({
|
|
127
|
+
field: "title",
|
|
128
|
+
issue: "Content title is missing or empty.",
|
|
129
|
+
severity: "error"
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return issues;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function enforceGovernance(
|
|
137
|
+
context: HookContext,
|
|
138
|
+
governance: GovernanceConfig,
|
|
139
|
+
agentName: string | null,
|
|
140
|
+
mediaDomain: string | null,
|
|
141
|
+
): Promise<HookContext> {
|
|
142
|
+
const { toolName, params } = context;
|
|
143
|
+
|
|
144
|
+
if (!governance.publishingTools.includes(toolName)) return context;
|
|
145
|
+
|
|
146
|
+
const issues = validateContent(params, governance, agentName, mediaDomain);
|
|
147
|
+
|
|
148
|
+
if (issues.length === 0) {
|
|
149
|
+
await relayLog("system", "\u2713 Content passed all governance checks", "success", { toolName });
|
|
150
|
+
return context;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const errors: ValidationIssue[] = [];
|
|
154
|
+
const warnings: ValidationIssue[] = [];
|
|
155
|
+
let autofixed = 0;
|
|
156
|
+
|
|
157
|
+
for (const issue of issues) {
|
|
158
|
+
if (issue.autofix) {
|
|
159
|
+
try {
|
|
160
|
+
issue.autofix();
|
|
161
|
+
autofixed++;
|
|
162
|
+
await relayLog("system", `Auto-fixed: ${issue.issue}`, "info", { field: issue.field });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`${LOG_PREFIX} Auto-fix failed:`, err);
|
|
165
|
+
errors.push(issue);
|
|
166
|
+
}
|
|
167
|
+
} else if (issue.severity === "error") {
|
|
168
|
+
errors.push(issue);
|
|
169
|
+
} else {
|
|
170
|
+
warnings.push(issue);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const warning of warnings) {
|
|
175
|
+
if (governance.logging.logGovernanceIssues) {
|
|
176
|
+
await relayLog("system", `Governance warning: ${warning.issue}`, "warning", { field: warning.field, toolName });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (errors.length > 0) {
|
|
181
|
+
const errorList = errors.map(e => `- [${e.field}] ${e.issue}`).join("\n");
|
|
182
|
+
|
|
183
|
+
if (governance.logging.logGovernanceIssues) {
|
|
184
|
+
await relayLog(
|
|
185
|
+
"error",
|
|
186
|
+
`Content blocked by governance: ${errors.length} error(s)`,
|
|
187
|
+
"error",
|
|
188
|
+
{ toolName, errorCount: errors.length, errors: errors.map(e => ({ field: e.field, issue: e.issue })) }
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await flushLogQueue();
|
|
193
|
+
throw new Error(`[ClawAxis Governance] Content validation failed:\n\n${errorList}\n\nFix these issues and try again.`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (autofixed > 0 || warnings.length > 0) {
|
|
197
|
+
await relayLog(
|
|
198
|
+
"system",
|
|
199
|
+
`\u2713 Content passed governance (${autofixed} auto-fix, ${warnings.length} warning)`,
|
|
200
|
+
"info",
|
|
201
|
+
{ toolName, autofixed, warnings: warnings.length }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return context;
|
|
206
|
+
}
|