@inquiryon/openclaw-amp-governance 1.0.9 → 1.1.2
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/hook/HOOK.md +20 -0
- package/hook/amp_config.json +8 -0
- package/hook/handler.ts +237 -0
- package/index.js +46 -6
- package/package.json +5 -2
package/hook/HOOK.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: amp
|
|
3
|
+
description: "AMP Governance and HITL integration for Inquiryon"
|
|
4
|
+
metadata:
|
|
5
|
+
openclaw:
|
|
6
|
+
emoji: "🔗"
|
|
7
|
+
# events: ["message", "task:start", "tool:*", "task:end"]
|
|
8
|
+
events: ["message", "message:sent"]
|
|
9
|
+
requires:
|
|
10
|
+
bins: ["node"]
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# AMP Hook
|
|
14
|
+
|
|
15
|
+
This hook integrates OpenClaw with the **Inquiryon AMP (Agent Management Platform)** to provide governance, activity logging, and lifecycle management.
|
|
16
|
+
|
|
17
|
+
## Integration Lifecycle
|
|
18
|
+
1. **Init**: Triggered on `task:start`.
|
|
19
|
+
2. **Log**: Triggered on `tool:end` for agentic actions.
|
|
20
|
+
3. **SetState**: Triggered on `task:end` to finalize the instance.
|
package/hook/handler.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
console.log('--- [AMP Hook] Logic Loaded — Phase 5 ---');
|
|
5
|
+
|
|
6
|
+
const configPath = path.join(process.env.HOME || '', '.openclaw/hooks/amp/amp_config.json');
|
|
7
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
8
|
+
|
|
9
|
+
const SESSION_FILE = '/tmp/amp-session-state.json';
|
|
10
|
+
const COMMAND_THRESHOLD = 30; // roll to new instance after this many tool calls
|
|
11
|
+
|
|
12
|
+
// In-memory state (cleared on process restart)
|
|
13
|
+
const sessionMap = new Map<string, string>(); // convKey → instanceId
|
|
14
|
+
const commandCounts = new Map<string, number>(); // convKey → tool call count
|
|
15
|
+
const seenMessages = new Set<string>(); // dedup guard
|
|
16
|
+
|
|
17
|
+
let _startupCleanupDone = false;
|
|
18
|
+
|
|
19
|
+
// ── HELPERS ───────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
async function ampLog(instanceId: string, message: string, level: string = 'INFO'): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
await fetch(`${config.AMP_BACKEND_URL}/api/log`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'X-API-Key': config.AMP_API_KEY, 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
instance_id: instanceId,
|
|
28
|
+
service: config.AGENT_NAME,
|
|
29
|
+
level,
|
|
30
|
+
message,
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
org_id: config.AMP_ORG_ID,
|
|
33
|
+
username: config.AMP_USERNAME
|
|
34
|
+
})
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('[AMP Hook] ampLog failed:', err);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function setInstanceFinished(instanceId: string): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await fetch(`${config.AMP_BACKEND_URL}/api/agent/setState`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'X-API-Key': config.AMP_API_KEY, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ agent_name: config.AGENT_NAME, instance_id: instanceId, state: 'finished' })
|
|
47
|
+
});
|
|
48
|
+
console.log(`[AMP Hook] Instance ${instanceId} marked finished.`);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('[AMP Hook] setInstanceFinished failed:', err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeSessionFile(instanceId: string, convKey: string): void {
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
|
57
|
+
instanceId,
|
|
58
|
+
convKey,
|
|
59
|
+
timestamp: new Date().toISOString()
|
|
60
|
+
}), 'utf-8');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[AMP Hook] Failed to write session file:', err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clearSessionFile(): void {
|
|
67
|
+
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function conversationKey(event: any): string {
|
|
71
|
+
const ctx = event.context || {};
|
|
72
|
+
const convId = ctx.conversationId || ctx.from || '';
|
|
73
|
+
const channel = ctx.channelId || event.channel || 'whatsapp';
|
|
74
|
+
return convId ? `${channel}:${convId}` : (event.taskId || 'global');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildToolMessage(event: any): string {
|
|
78
|
+
const tool = event.toolName || event.tool || event.name || 'unknown-tool';
|
|
79
|
+
const parts: string[] = [`Tool: ${tool}`];
|
|
80
|
+
const raw_input = event.toolInput ?? event.input ?? event.args;
|
|
81
|
+
if (raw_input != null) {
|
|
82
|
+
const s = typeof raw_input === 'string' ? raw_input : JSON.stringify(raw_input);
|
|
83
|
+
parts.push(`Input: ${s.substring(0, 200)}`);
|
|
84
|
+
}
|
|
85
|
+
const raw_output = event.toolOutput ?? event.output ?? event.result;
|
|
86
|
+
if (raw_output != null) {
|
|
87
|
+
const s = typeof raw_output === 'string' ? raw_output : JSON.stringify(raw_output);
|
|
88
|
+
parts.push(`Output: ${s.substring(0, 200)}`);
|
|
89
|
+
}
|
|
90
|
+
if (event.durationMs != null) parts.push(`Duration: ${event.durationMs}ms`);
|
|
91
|
+
if (event.success === false) parts.push('Status: FAILED');
|
|
92
|
+
return parts.join(' | ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── INSTANCE LIFECYCLE ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* On first event after process restart, finalize any orphaned instance left
|
|
99
|
+
* in the session file from the previous run, then clear the file.
|
|
100
|
+
*/
|
|
101
|
+
async function startupCleanup(): Promise<void> {
|
|
102
|
+
if (_startupCleanupDone) return;
|
|
103
|
+
_startupCleanupDone = true;
|
|
104
|
+
try {
|
|
105
|
+
const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
|
|
106
|
+
if (session?.instanceId) {
|
|
107
|
+
console.log(`[AMP Hook] Startup: finalizing orphaned instance ${session.instanceId}`);
|
|
108
|
+
await setInstanceFinished(session.instanceId);
|
|
109
|
+
}
|
|
110
|
+
} catch {} // No session file or parse error — nothing to clean up
|
|
111
|
+
clearSessionFile();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function finalizeInstance(convKey: string, reason: string): Promise<void> {
|
|
115
|
+
const instanceId = sessionMap.get(convKey);
|
|
116
|
+
if (!instanceId) return;
|
|
117
|
+
console.log(`[AMP Hook] Finalizing ${instanceId} (${reason})`);
|
|
118
|
+
await setInstanceFinished(instanceId);
|
|
119
|
+
sessionMap.delete(convKey);
|
|
120
|
+
commandCounts.delete(convKey);
|
|
121
|
+
clearSessionFile();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function initInstance(convKey: string, prompt: string, metadata: object): Promise<string | null> {
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetch(`${config.AMP_BACKEND_URL}/api/agent/init`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'X-API-Key': config.AMP_API_KEY, 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
agent_name: config.AGENT_NAME,
|
|
131
|
+
prompt,
|
|
132
|
+
auto_start: true,
|
|
133
|
+
config: { agent_mode: 'polling' },
|
|
134
|
+
metadata: { source: 'openclaw-whatsapp', org_id: config.AMP_ORG_ID, ...metadata }
|
|
135
|
+
})
|
|
136
|
+
});
|
|
137
|
+
const data = await response.json();
|
|
138
|
+
console.log(`[AMP Hook] Init response: ${response.status} | instance: ${data.instance_id || 'NONE'}`);
|
|
139
|
+
if (data.instance_id) {
|
|
140
|
+
sessionMap.set(convKey, data.instance_id);
|
|
141
|
+
commandCounts.set(convKey, 0);
|
|
142
|
+
writeSessionFile(data.instance_id, convKey);
|
|
143
|
+
return data.instance_id;
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error('[AMP Hook] initInstance failed:', err);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check command threshold. If reached: log, finalize current instance,
|
|
153
|
+
* start a new one. Returns the (possibly new) active instanceId.
|
|
154
|
+
*/
|
|
155
|
+
async function checkThreshold(convKey: string): Promise<string | null> {
|
|
156
|
+
const count = (commandCounts.get(convKey) || 0) + 1;
|
|
157
|
+
commandCounts.set(convKey, count);
|
|
158
|
+
|
|
159
|
+
if (count >= COMMAND_THRESHOLD) {
|
|
160
|
+
const oldInstanceId = sessionMap.get(convKey);
|
|
161
|
+
if (oldInstanceId) {
|
|
162
|
+
const msg = `Reached ${COMMAND_THRESHOLD}-command threshold — rolling to a new agent instance.`;
|
|
163
|
+
console.log(`[AMP Hook] ${msg}`);
|
|
164
|
+
await ampLog(oldInstanceId, msg, 'INFO');
|
|
165
|
+
await finalizeInstance(convKey, `${COMMAND_THRESHOLD}-command threshold`);
|
|
166
|
+
}
|
|
167
|
+
// Start fresh instance (reuse last known prompt/metadata not available here — use placeholder)
|
|
168
|
+
const newId = await initInstance(convKey, 'Continued session (threshold rollover)', {});
|
|
169
|
+
if (newId) commandCounts.set(convKey, 1); // count the current call
|
|
170
|
+
return newId;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return sessionMap.get(convKey) || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── MAIN HOOK ─────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export default async function ampHook(event: any) {
|
|
179
|
+
const type = event.type;
|
|
180
|
+
const convKey = conversationKey(event);
|
|
181
|
+
|
|
182
|
+
const ctx = event.context || {};
|
|
183
|
+
const messageText = ctx.content || event.text || event.input || 'WhatsApp Inbound';
|
|
184
|
+
const senderName = ctx.metadata?.senderName || ctx.from || 'unknown';
|
|
185
|
+
const messageId = ctx.messageId || '';
|
|
186
|
+
|
|
187
|
+
console.log(`[AMP Hook] Event: ${type} | Conv: ${convKey} | Sender: ${senderName}`);
|
|
188
|
+
|
|
189
|
+
// ── STARTUP CLEANUP ────────────────────────────────────────────────────────
|
|
190
|
+
await startupCleanup();
|
|
191
|
+
|
|
192
|
+
// ── 1. INBOUND MESSAGE ─────────────────────────────────────────────────────
|
|
193
|
+
// Init a new instance only if this conversation has no active instance.
|
|
194
|
+
// Subsequent messages in the same conversation reuse the existing instance.
|
|
195
|
+
const isInbound = type === 'message' && !!messageId;
|
|
196
|
+
|
|
197
|
+
if (isInbound) {
|
|
198
|
+
// Deduplicate repeated message events
|
|
199
|
+
if (seenMessages.has(messageId)) return;
|
|
200
|
+
seenMessages.add(messageId);
|
|
201
|
+
if (seenMessages.size > 50) seenMessages.delete(seenMessages.values().next().value);
|
|
202
|
+
|
|
203
|
+
if (!sessionMap.has(convKey)) {
|
|
204
|
+
// No active instance for this conversation — create one
|
|
205
|
+
console.log(`[AMP Hook] New conversation. Init AMP instance for: ${messageText.substring(0, 60)}`);
|
|
206
|
+
const instanceId = await initInstance(convKey, messageText, {
|
|
207
|
+
channel: ctx.channelId || 'whatsapp',
|
|
208
|
+
sender: senderName,
|
|
209
|
+
sender_phone: ctx.from || '',
|
|
210
|
+
message_id: messageId,
|
|
211
|
+
});
|
|
212
|
+
if (instanceId) {
|
|
213
|
+
await ampLog(instanceId, `User prompt: '${messageText}'`);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// Existing instance — log the new message and reuse
|
|
217
|
+
const instanceId = sessionMap.get(convKey)!;
|
|
218
|
+
console.log(`[AMP Hook] Existing instance ${instanceId} — reusing for new message.`);
|
|
219
|
+
await ampLog(instanceId, `User prompt: '${messageText}'`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── 2. TOOL LOGGING + THRESHOLD CHECK ─────────────────────────────────────
|
|
224
|
+
if (type.startsWith('tool:')) {
|
|
225
|
+
const instanceId = await checkThreshold(convKey);
|
|
226
|
+
if (instanceId) {
|
|
227
|
+
try {
|
|
228
|
+
const message = buildToolMessage(event);
|
|
229
|
+
console.log(`[AMP Hook] Logging tool: ${message.substring(0, 100)}`);
|
|
230
|
+
await ampLog(instanceId, message, event.success === false ? 'ERROR' : 'INFO');
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error('[AMP Hook] Tool log failed:', err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
}
|
package/index.js
CHANGED
|
@@ -8,9 +8,7 @@ const CONFIG_FILE = `${process.env.HOME}/.openclaw/hooks/amp/amp_config.json`;
|
|
|
8
8
|
// Tools that are internal/noisy — skip logging and policy checks
|
|
9
9
|
const SKIP_TOOLS = new Set(['session_status', 'heartbeat']);
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
const HITL_POLL_INTERVAL_MS = 3000; // 3 seconds between polls
|
|
13
|
-
const HITL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minute max wait for human
|
|
11
|
+
const HITL_POLL_INTERVAL_MS = 3000; // 3 seconds between polls
|
|
14
12
|
|
|
15
13
|
let config = null;
|
|
16
14
|
try {
|
|
@@ -20,6 +18,9 @@ try {
|
|
|
20
18
|
console.error('[AMP Governance] Failed to load config:', err.message);
|
|
21
19
|
}
|
|
22
20
|
|
|
21
|
+
// HITL timeout — configurable via HITL_TIMEOUT_MINUTES in amp_config.json (default: 10)
|
|
22
|
+
const HITL_TIMEOUT_MS = (config?.HITL_TIMEOUT_MINUTES ?? 10) * 60 * 1000;
|
|
23
|
+
|
|
23
24
|
// Module-level instance cache so we only init once per process lifetime
|
|
24
25
|
let _instanceId = null;
|
|
25
26
|
|
|
@@ -29,6 +30,42 @@ let _lastSender = null; // { from: string, channelId: string }
|
|
|
29
30
|
// Set by register() so notifyUser can use the runtime API directly
|
|
30
31
|
let _runtime = null;
|
|
31
32
|
|
|
33
|
+
// ── HOOK AUTO-DEPLOY ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* On first install (or if the hook dir is missing), copy the bundled hook
|
|
37
|
+
* files from the plugin's install directory into ~/.openclaw/hooks/amp/.
|
|
38
|
+
* Never overwrites an existing amp_config.json so user credentials are safe.
|
|
39
|
+
*/
|
|
40
|
+
function deployHookFilesIfNeeded(pluginRootDir) {
|
|
41
|
+
const hookSrc = `${pluginRootDir}/hook`;
|
|
42
|
+
const hookDest = `${process.env.HOME}/.openclaw/hooks/amp`;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (!fs.existsSync(hookSrc)) {
|
|
46
|
+
console.warn('[AMP Governance] Hook source dir not found — skipping auto-deploy.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
fs.mkdirSync(hookDest, { recursive: true });
|
|
50
|
+
|
|
51
|
+
for (const file of ['handler.ts', 'HOOK.md']) {
|
|
52
|
+
const dest = `${hookDest}/${file}`;
|
|
53
|
+
fs.copyFileSync(`${hookSrc}/${file}`, dest);
|
|
54
|
+
console.log(`[AMP Governance] Deployed hook file: ${dest}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Only write amp_config.json if it doesn't already exist
|
|
58
|
+
const configDest = `${hookDest}/amp_config.json`;
|
|
59
|
+
if (!fs.existsSync(configDest)) {
|
|
60
|
+
fs.copyFileSync(`${hookSrc}/amp_config.json`, configDest);
|
|
61
|
+
console.log(`[AMP Governance] Created config template: ${configDest}`);
|
|
62
|
+
console.log('[AMP Governance] ACTION REQUIRED: edit amp_config.json with your AMP credentials.');
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error('[AMP Governance] Hook auto-deploy failed:', err.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
function readSession() {
|
|
33
70
|
try {
|
|
34
71
|
return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
|
|
@@ -156,9 +193,7 @@ async function requestHitlEval(instanceId, tool, params) {
|
|
|
156
193
|
hitl: {
|
|
157
194
|
enable: true,
|
|
158
195
|
when: 'policy',
|
|
159
|
-
who
|
|
160
|
-
what: 'approval',
|
|
161
|
-
where: 'amp',
|
|
196
|
+
// who/what/where resolved by the backend from the policy's hitl_spec
|
|
162
197
|
},
|
|
163
198
|
}),
|
|
164
199
|
});
|
|
@@ -392,6 +427,11 @@ export default {
|
|
|
392
427
|
api.logger.info('AMP Governance registered. Phase 4 - eval policy enforcement active.');
|
|
393
428
|
_runtime = api.runtime;
|
|
394
429
|
|
|
430
|
+
// ── HOOK AUTO-DEPLOY on gateway start ────────────────────────────────────
|
|
431
|
+
api.on('gateway_start', () => {
|
|
432
|
+
if (api.rootDir) deployHookFilesIfNeeded(api.rootDir);
|
|
433
|
+
});
|
|
434
|
+
|
|
395
435
|
// ── SESSION RESET: clear instance cache on /new or /reset ────────────────
|
|
396
436
|
api.on('session_start', () => {
|
|
397
437
|
console.log('[AMP Governance] Session started — clearing instance cache.');
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inquiryon/openclaw-amp-governance",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "AMP governance plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"index.js",
|
|
8
|
-
"openclaw.plugin.json"
|
|
8
|
+
"openclaw.plugin.json",
|
|
9
|
+
"hook/handler.ts",
|
|
10
|
+
"hook/HOOK.md",
|
|
11
|
+
"hook/amp_config.json"
|
|
9
12
|
],
|
|
10
13
|
"publishConfig": {
|
|
11
14
|
"access": "public"
|