@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 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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "AMP_BACKEND_URL": "https://amp.your-org.com",
3
+ "AMP_API_KEY": "amp_k_...",
4
+ "AMP_ORG_ID": "O-...",
5
+ "AGENT_NAME": "open-claw-1234",
6
+ "AMP_USERNAME": "you@example.com",
7
+ "HITL_TIMEOUT_MINUTES": 10
8
+ }
@@ -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
- // HITL polling config
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: config.AMP_USERNAME,
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.0.9",
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"