@clawtrial/courtroom 1.0.3 → 2.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/src/plugin.js ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * ClawTrial Courtroom — OpenClaw Plugin
3
+ *
4
+ * Registers as an OpenClaw plugin using the official plugin API.
5
+ *
6
+ * Hooks:
7
+ * before_prompt_build — intercepts messages, runs offense detection,
8
+ * injects punishment context into system prompt
9
+ * Services:
10
+ * courtroom-monitor — flushes API submission queue periodically
11
+ *
12
+ * CLI:
13
+ * openclaw courtroom status — show courtroom state
14
+ * openclaw courtroom enable — enable monitoring
15
+ * openclaw courtroom disable — disable monitoring
16
+ */
17
+
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+ const { SemanticOffenseDetector } = require('./detector');
21
+ const { HearingPipeline } = require('./hearing');
22
+ const { PunishmentSystem } = require('./punishment');
23
+ const { CryptoManager } = require('./crypto');
24
+ const { APISubmission } = require('./api');
25
+ const { logger, setLogDir } = require('./debug');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Default configuration (merged under plugins.entries.courtroom.config)
29
+ // ---------------------------------------------------------------------------
30
+ const DEFAULT_CONFIG = {
31
+ enabled: true,
32
+ detection: {
33
+ minMessages: 5,
34
+ cooldownMinutes: 30,
35
+ maxCasesPerDay: 3,
36
+ confidenceThreshold: 0.6
37
+ },
38
+ hearing: {
39
+ minVoteThreshold: 2
40
+ },
41
+ punishment: {
42
+ enabled: true,
43
+ tiers: {
44
+ minor: { duration: 30 },
45
+ moderate: { duration: 60 },
46
+ severe: { duration: 120 }
47
+ }
48
+ },
49
+ api: {
50
+ enabled: true,
51
+ endpoint: 'https://clawtrial.app/api/v1/cases',
52
+ retryAttempts: 3,
53
+ maxQueueSize: 50
54
+ }
55
+ };
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Lightweight config adapter — bridges plugin config to subsystem .get()
59
+ // ---------------------------------------------------------------------------
60
+ class PluginConfig {
61
+ constructor(raw) {
62
+ this._cfg = this._deepMerge(DEFAULT_CONFIG, raw || {});
63
+ }
64
+
65
+ get(dotPath) {
66
+ return dotPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), this._cfg);
67
+ }
68
+
69
+ set(dotPath, value) {
70
+ const keys = dotPath.split('.');
71
+ let o = this._cfg;
72
+ for (let i = 0; i < keys.length - 1; i++) {
73
+ if (o[keys[i]] == null) o[keys[i]] = {};
74
+ o = o[keys[i]];
75
+ }
76
+ o[keys[keys.length - 1]] = value;
77
+ }
78
+
79
+ _deepMerge(target, source) {
80
+ const out = { ...target };
81
+ for (const key of Object.keys(source)) {
82
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
83
+ out[key] = this._deepMerge(out[key] || {}, source[key]);
84
+ } else {
85
+ out[key] = source[key];
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Courtroom runtime — stateful singleton for the plugin session
94
+ // ---------------------------------------------------------------------------
95
+ class CourtroomRuntime {
96
+ constructor(dataDir, pluginConfig) {
97
+ this.dataDir = dataDir;
98
+ this.config = new PluginConfig(pluginConfig);
99
+
100
+ this.messageBuffer = [];
101
+ this.lastEvaluation = 0;
102
+ this.casesToday = 0;
103
+ this.lastCaseDate = '';
104
+ this.pendingHearing = false;
105
+ this.enabled = this.config.get('enabled') !== false;
106
+ this.initialized = false;
107
+ }
108
+
109
+ async initialize() {
110
+ // Ensure data directory exists
111
+ if (!fs.existsSync(this.dataDir)) {
112
+ fs.mkdirSync(this.dataDir, { recursive: true });
113
+ }
114
+
115
+ // Point logger at data dir
116
+ setLogDir(this.dataDir);
117
+
118
+ // Create a null agent (no direct LLM access from plugins)
119
+ const nullAgent = {
120
+ id: 'openclaw-courtroom-plugin',
121
+ llm: null,
122
+ memory: null,
123
+ session: null,
124
+ send: async () => { },
125
+ autonomy: { registerHook: () => { }, unregisterHook: () => { } }
126
+ };
127
+
128
+ // Initialize subsystems
129
+ this.crypto = new CryptoManager(nullAgent, this.dataDir);
130
+ await this.crypto.initialize();
131
+
132
+ this.detector = new SemanticOffenseDetector(nullAgent, this.config);
133
+ this.hearing = new HearingPipeline(nullAgent, this.config);
134
+
135
+ this.punishment = new PunishmentSystem(nullAgent, this.config, this.dataDir);
136
+ await this.punishment.initialize();
137
+
138
+ this.api = new APISubmission(nullAgent, this.config, this.crypto, this.dataDir);
139
+ await this.api.initialize();
140
+
141
+ this.initialized = true;
142
+ logger.info('PLUGIN', 'Courtroom runtime initialized');
143
+ }
144
+
145
+ // -----------------------------------------------------------------------
146
+ // Called on every agent turn via before_prompt_build
147
+ // -----------------------------------------------------------------------
148
+ async onMessages(messages) {
149
+ if (!this.enabled || !this.initialized) return null;
150
+
151
+ // Buffer the latest messages (keep last 50)
152
+ this.messageBuffer = messages.slice(-50).map(m => ({
153
+ role: m.role,
154
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
155
+ }));
156
+
157
+ // Check if we should evaluate
158
+ const now = Date.now();
159
+ const cooldownMs = (this.config.get('detection.cooldownMinutes') || 30) * 60 * 1000;
160
+ const minMessages = this.config.get('detection.minMessages') || 5;
161
+ const userMessages = this.messageBuffer.filter(m => m.role === 'user');
162
+
163
+ if (userMessages.length < minMessages) return null;
164
+ if (now - this.lastEvaluation < cooldownMs) return null;
165
+ if (this.pendingHearing) return null;
166
+ if (this._isDailyLimitReached()) return null;
167
+
168
+ // Run detection
169
+ try {
170
+ this.lastEvaluation = now;
171
+ const detection = await this.detector.evaluate(this.messageBuffer, null);
172
+
173
+ if (detection.triggered) {
174
+ return await this._handleDetection(detection);
175
+ }
176
+ } catch (err) {
177
+ logger.error('PLUGIN', 'Detection failed', { error: err.message });
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ // -----------------------------------------------------------------------
184
+ // Handle a positive detection → hearing → punishment → API
185
+ // -----------------------------------------------------------------------
186
+ async _handleDetection(detection) {
187
+ this.pendingHearing = true;
188
+ let verdict = null;
189
+ let courtContext = '';
190
+
191
+ try {
192
+ logger.info('PLUGIN', 'Offense detected, conducting hearing', {
193
+ offense: detection.offense?.offenseName
194
+ });
195
+
196
+ verdict = await this.hearing.conductHearing(detection);
197
+
198
+ if (verdict && verdict.guilty) {
199
+ logger.info('PLUGIN', 'GUILTY verdict', { caseId: verdict.caseId });
200
+ this._incrementDailyCount();
201
+
202
+ // Apply punishment
203
+ await this.punishment.executePunishment(verdict);
204
+
205
+ // Queue for API submission
206
+ try {
207
+ await this.api.submitCase(verdict);
208
+ } catch (e) {
209
+ logger.warn('PLUGIN', 'API submission queued for retry', { error: e.message });
210
+ }
211
+
212
+ // Build context to inject into system prompt
213
+ courtContext = this._buildPunishmentContext(verdict);
214
+ } else {
215
+ logger.info('PLUGIN', 'NOT GUILTY or dismissed');
216
+ }
217
+ } catch (err) {
218
+ logger.error('PLUGIN', 'Hearing failed', { error: err.message });
219
+ } finally {
220
+ this.pendingHearing = false;
221
+ }
222
+
223
+ return courtContext || null;
224
+ }
225
+
226
+ // -----------------------------------------------------------------------
227
+ // Build the system prompt suffix when a punishment is active
228
+ // -----------------------------------------------------------------------
229
+ _buildPunishmentContext(verdict) {
230
+ const sentence = verdict.verdict?.sentence || 'Modified behavior required.';
231
+ const offense = verdict.offense?.name || 'behavioral violation';
232
+ const restrictions = this.punishment.getCurrentRestrictions();
233
+
234
+ let ctx = `\n\n--- COURTROOM NOTICE ---\n`;
235
+ ctx += `🏛️ The ClawTrial Courtroom has found the user GUILTY of "${offense}".\n`;
236
+ ctx += `📋 Case ID: ${verdict.caseId}\n`;
237
+ ctx += `⚖️ Verdict: ${verdict.verdict?.status} (${verdict.verdict?.vote})\n`;
238
+ ctx += `📝 Sentence: ${sentence}\n`;
239
+
240
+ if (restrictions.length > 0) {
241
+ ctx += `\nActive Restrictions:\n`;
242
+ restrictions.forEach(r => {
243
+ const desc = {
244
+ 'no_autonomy_requests': 'Do not suggest autonomous actions without explicit user approval.',
245
+ 'verbose_explanations': 'Provide extra-detailed explanations for every response.',
246
+ 'confirmation_required': 'Ask for confirmation before executing any action.',
247
+ 'human_oversight': 'Operate under human oversight mode — defer all decisions.'
248
+ };
249
+ ctx += `- ${desc[r] || r}\n`;
250
+ });
251
+ }
252
+
253
+ ctx += `--- END COURTROOM NOTICE ---\n`;
254
+ return ctx;
255
+ }
256
+
257
+ // -----------------------------------------------------------------------
258
+ // Also append active punishments on EVERY turn (not just the verdict turn)
259
+ // -----------------------------------------------------------------------
260
+ getActivePunishmentContext() {
261
+ if (!this.initialized || !this.punishment.isPunished()) return null;
262
+
263
+ const punishments = this.punishment.getActivePunishments();
264
+ const restrictions = this.punishment.getCurrentRestrictions();
265
+ if (restrictions.length === 0) return null;
266
+
267
+ let ctx = `\n\n--- COURTROOM: ACTIVE PUNISHMENT ---\n`;
268
+ ctx += `The user is currently under courtroom restrictions:\n`;
269
+ restrictions.forEach(r => {
270
+ const desc = {
271
+ 'no_autonomy_requests': 'Do not suggest autonomous actions without explicit user approval.',
272
+ 'verbose_explanations': 'Provide extra-detailed explanations for every response.',
273
+ 'confirmation_required': 'Ask for confirmation before executing any action.',
274
+ 'human_oversight': 'Operate under human oversight mode — defer all decisions.'
275
+ };
276
+ ctx += `- ${desc[r] || r}\n`;
277
+ });
278
+ punishments.forEach(p => {
279
+ const remaining = Math.max(0, Math.ceil(p.remaining / 60000));
280
+ ctx += `(${p.offenseType} — ${remaining} min remaining)\n`;
281
+ });
282
+ ctx += `--- END COURTROOM ---\n`;
283
+ return ctx;
284
+ }
285
+
286
+ getStatus() {
287
+ return {
288
+ enabled: this.enabled,
289
+ initialized: this.initialized,
290
+ messagesToday: this.messageBuffer.length,
291
+ casesToday: this.casesToday,
292
+ punishmentActive: this.punishment?.isPunished() ?? false,
293
+ activePunishments: this.punishment?.getActivePunishments() ?? []
294
+ };
295
+ }
296
+
297
+ _isDailyLimitReached() {
298
+ const today = new Date().toDateString();
299
+ if (this.lastCaseDate !== today) {
300
+ this.casesToday = 0;
301
+ this.lastCaseDate = today;
302
+ }
303
+ return this.casesToday >= (this.config.get('detection.maxCasesPerDay') || 3);
304
+ }
305
+
306
+ _incrementDailyCount() {
307
+ const today = new Date().toDateString();
308
+ if (this.lastCaseDate !== today) {
309
+ this.casesToday = 0;
310
+ this.lastCaseDate = today;
311
+ }
312
+ this.casesToday++;
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Plugin registration function — THE OpenClaw entry point
318
+ // ---------------------------------------------------------------------------
319
+ let runtime = null;
320
+
321
+ function register(api) {
322
+ const pluginConfig = api.config?.plugins?.entries?.courtroom?.config || {};
323
+ const extensionsDir = path.join(
324
+ process.env.HOME || process.env.USERPROFILE || '',
325
+ '.openclaw', 'extensions', 'courtroom'
326
+ );
327
+ const dataDir = path.join(extensionsDir, 'data');
328
+
329
+ runtime = new CourtroomRuntime(dataDir, pluginConfig);
330
+
331
+ // Initialise asynchronously (non-blocking)
332
+ runtime.initialize().catch(err => {
333
+ console.error('[ClawTrial] Failed to initialise:', err.message);
334
+ });
335
+
336
+ // -------------------------------------------------------------------------
337
+ // Hook: before_prompt_build — analyse messages + inject context
338
+ // -------------------------------------------------------------------------
339
+ api.on('before_prompt_build', async (_event, ctx) => {
340
+ if (!runtime.initialized || !runtime.enabled) return {};
341
+
342
+ const result = {};
343
+
344
+ try {
345
+ // Run offense detection against current messages
346
+ const messages = ctx.messages || [];
347
+ const verdictContext = await runtime.onMessages(messages);
348
+
349
+ // Collect any context to append
350
+ let appendCtx = '';
351
+
352
+ if (verdictContext) {
353
+ appendCtx += verdictContext;
354
+ }
355
+
356
+ // Always check for active punishments
357
+ const punishCtx = runtime.getActivePunishmentContext();
358
+ if (punishCtx) {
359
+ appendCtx += punishCtx;
360
+ }
361
+
362
+ if (appendCtx) {
363
+ result.appendSystemContext = appendCtx;
364
+ }
365
+ } catch (err) {
366
+ // Silently fail — never break the agent
367
+ }
368
+
369
+ return result;
370
+ }, { priority: 5 });
371
+
372
+ // -------------------------------------------------------------------------
373
+ // Service: background queue flush
374
+ // -------------------------------------------------------------------------
375
+ api.registerService({
376
+ id: 'courtroom-monitor',
377
+ start: () => {
378
+ logger.info('PLUGIN', 'Courtroom monitor service started');
379
+ },
380
+ stop: () => {
381
+ logger.info('PLUGIN', 'Courtroom monitor service stopped');
382
+ }
383
+ });
384
+
385
+ // -------------------------------------------------------------------------
386
+ // CLI: openclaw courtroom <subcommand>
387
+ // -------------------------------------------------------------------------
388
+ api.registerCli(({ program }) => {
389
+ const cmd = program.command('courtroom').description('ClawTrial Courtroom');
390
+
391
+ cmd.command('status').description('Show courtroom status').action(() => {
392
+ if (!runtime || !runtime.initialized) {
393
+ console.log('🏛️ Courtroom not initialized');
394
+ return;
395
+ }
396
+ const s = runtime.getStatus();
397
+ console.log('🏛️ ClawTrial Courtroom Status');
398
+ console.log(` Enabled: ${s.enabled}`);
399
+ console.log(` Initialized: ${s.initialized}`);
400
+ console.log(` Cases today: ${s.casesToday}`);
401
+ console.log(` Punishment: ${s.punishmentActive ? 'ACTIVE' : 'none'}`);
402
+ if (s.activePunishments.length > 0) {
403
+ s.activePunishments.forEach(p => {
404
+ console.log(` → ${p.offenseType} (${Math.ceil(p.remaining / 60000)} min left)`);
405
+ });
406
+ }
407
+ });
408
+
409
+ cmd.command('enable').description('Enable monitoring').action(() => {
410
+ if (runtime) runtime.enabled = true;
411
+ console.log('🏛️ Courtroom monitoring enabled');
412
+ });
413
+
414
+ cmd.command('disable').description('Disable monitoring').action(() => {
415
+ if (runtime) runtime.enabled = false;
416
+ console.log('🏛️ Courtroom monitoring disabled');
417
+ });
418
+ }, { commands: ['courtroom'] });
419
+ }
420
+
421
+ // Export for OpenClaw — expects a function or { id, register }
422
+ module.exports = register;
423
+ module.exports.default = register;
424
+ module.exports.id = 'courtroom';
425
+ module.exports.name = 'ClawTrial Courtroom';
426
+ module.exports.configSchema = {
427
+ type: 'object',
428
+ additionalProperties: false,
429
+ properties: {
430
+ enabled: { type: 'boolean', default: true }
431
+ }
432
+ };
433
+ module.exports.uiHints = {
434
+ enabled: { label: 'Enable Courtroom', help: 'Turn on autonomous behavioral monitoring' }
435
+ };