PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / agents / store.php

store.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.9, at includes/agents/store.php

1,271 lines 41.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: definition store (user meta on the agent row).
4 *
5 * Everything that defines an agent beyond its `wp_users` row lives as
6 * user meta on that row, in one `_openstation_agent_*` key family:
7 *
8 * - `_desktop_mode_agent` marker ('1') — the existence test
9 * - `_desktop_mode_agent_description` "when to use" short text
10 * - `_desktop_mode_agent_instructions` system prompt (markdown)
11 * - `_desktop_mode_agent_abilities` JSON array of ability slugs
12 * - `_desktop_mode_agent_triggers` JSON array of { kind, config }
13 * - `_desktop_mode_agent_model` model override (unused by the
14 * runner until the Core AI Client
15 * exposes model selection)
16 * - `_desktop_mode_agent_rate_limit` invocations/hour, 0 = default
17 * - `_desktop_mode_agent_created_by` creating user id (audit aid)
18 *
19 * User meta has no revisions — the audit trail for definition changes
20 * is the `openstation_agent_{created,updated,deleted}` actions fired
21 * from this module's orchestrators, each carrying before/after values
22 * so logging plugins can persist a history.
23 *
24 * This module owns every key: constants, `register_meta()` calls,
25 * sanitization, getters/setters, and the create/update orchestrators
26 * the REST surface calls. `identity.php` owns the user row itself.
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 require_once OPENSTATION_DIR . 'includes/agents/guard.php';
34
35 /**
36 * Meta keys owned by the agents store. Constants so the other layer
37 * files reuse them instead of typing the literals.
38 *
39 * `OPENSTATION_AGENT_USER_MARKER_META` is the exception — it lives in
40 * guard.php, which loads unconditionally, because the agent test has to
41 * resolve even when this module does not load.
42 *
43 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
44 * persisted or externally-visible identifier, so renaming it would
45 * orphan data already written by live installs (or break a live
46 * URL). The mismatch between this constant's name and its value is
47 * deliberate — it is NOT a half-finished rename.
48 */
49 const OPENSTATION_AGENT_DESCRIPTION_META = '_desktop_mode_agent_description';
50 /**
51 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
52 * persisted or externally-visible identifier, so renaming it would
53 * orphan data already written by live installs (or break a live
54 * URL). The mismatch between this constant's name and its value is
55 * deliberate — it is NOT a half-finished rename.
56 */
57 const OPENSTATION_AGENT_INSTRUCTIONS_META = '_desktop_mode_agent_instructions';
58 /**
59 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
60 * persisted or externally-visible identifier, so renaming it would
61 * orphan data already written by live installs (or break a live
62 * URL). The mismatch between this constant's name and its value is
63 * deliberate — it is NOT a half-finished rename.
64 */
65 const OPENSTATION_AGENT_ABILITIES_META = '_desktop_mode_agent_abilities';
66 /**
67 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
68 * persisted or externally-visible identifier, so renaming it would
69 * orphan data already written by live installs (or break a live
70 * URL). The mismatch between this constant's name and its value is
71 * deliberate — it is NOT a half-finished rename.
72 */
73 const OPENSTATION_AGENT_TRIGGERS_META = '_desktop_mode_agent_triggers';
74 /**
75 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
76 * persisted or externally-visible identifier, so renaming it would
77 * orphan data already written by live installs (or break a live
78 * URL). The mismatch between this constant's name and its value is
79 * deliberate — it is NOT a half-finished rename.
80 */
81 const OPENSTATION_AGENT_MODEL_META = '_desktop_mode_agent_model';
82 /**
83 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
84 * persisted or externally-visible identifier, so renaming it would
85 * orphan data already written by live installs (or break a live
86 * URL). The mismatch between this constant's name and its value is
87 * deliberate — it is NOT a half-finished rename.
88 */
89 const OPENSTATION_AGENT_RATE_LIMIT_META = '_desktop_mode_agent_rate_limit';
90 /**
91 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
92 * persisted or externally-visible identifier, so renaming it would
93 * orphan data already written by live installs (or break a live
94 * URL). The mismatch between this constant's name and its value is
95 * deliberate — it is NOT a half-finished rename.
96 */
97 const OPENSTATION_AGENT_CREATED_BY_META = '_desktop_mode_agent_created_by';
98 /**
99 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
100 * persisted or externally-visible identifier, so renaming it would
101 * orphan data already written by live installs (or break a live
102 * URL). The mismatch between this constant's name and its value is
103 * deliberate — it is NOT a half-finished rename.
104 */
105 const OPENSTATION_AGENT_VIBES_META = '_desktop_mode_agent_vibes';
106
107 /**
108 * Longest voice line an agent may carry.
109 *
110 * Short enough that it stays a voice rather than becoming a second
111 * instruction block by volume.
112 */
113 const OPENSTATION_AGENT_VIBES_MAX_LENGTH = 120;
114 /**
115 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
116 * persisted or externally-visible identifier, so renaming it would
117 * orphan data already written by live installs (or break a live
118 * URL). The mismatch between this constant's name and its value is
119 * deliberate — it is NOT a half-finished rename.
120 */
121 const OPENSTATION_AGENT_FACE_META = '_desktop_mode_agent_face';
122 /**
123 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
124 * persisted or externally-visible identifier, so renaming it would
125 * orphan data already written by live installs (or break a live
126 * URL). The mismatch between this constant's name and its value is
127 * deliberate — it is NOT a half-finished rename.
128 */
129 const OPENSTATION_AGENT_FACE_SEED_META = '_desktop_mode_agent_face_seed';
130
131 /**
132 * Every meta key the store writes — the privacy eraser and any future
133 * cleanup path iterate this list instead of re-typing the constants.
134 *
135 * @return string[]
136 */
137 function openstation_agent_meta_keys() {
138 return array(
139 OPENSTATION_AGENT_USER_MARKER_META,
140 OPENSTATION_AGENT_DESCRIPTION_META,
141 OPENSTATION_AGENT_INSTRUCTIONS_META,
142 OPENSTATION_AGENT_ABILITIES_META,
143 OPENSTATION_AGENT_TRIGGERS_META,
144 OPENSTATION_AGENT_MODEL_META,
145 OPENSTATION_AGENT_RATE_LIMIT_META,
146 OPENSTATION_AGENT_CREATED_BY_META,
147 OPENSTATION_AGENT_VIBES_META,
148 OPENSTATION_AGENT_FACE_META,
149 OPENSTATION_AGENT_FACE_SEED_META,
150 );
151 }
152
153 /**
154 * Register the user-meta keys.
155 *
156 * `show_in_rest` stays false on every key — the module's own REST
157 * surface (rest.php) is the only reader/writer; core `wp/v2/users`
158 * never exposes agent definitions.
159 *
160 * @return void
161 */
162 function openstation_agents_register_user_meta() {
163 $auth = static function () {
164 return current_user_can( 'edit_users' );
165 };
166
167 register_meta(
168 'user',
169 OPENSTATION_AGENT_DESCRIPTION_META,
170 array(
171 'type' => 'string',
172 'single' => true,
173 'default' => '',
174 'show_in_rest' => false,
175 'sanitize_callback' => 'sanitize_text_field',
176 'auth_callback' => $auth,
177 )
178 );
179 register_meta(
180 'user',
181 OPENSTATION_AGENT_INSTRUCTIONS_META,
182 array(
183 'type' => 'string',
184 'single' => true,
185 'default' => '',
186 'show_in_rest' => false,
187 'sanitize_callback' => 'wp_kses_post',
188 'auth_callback' => $auth,
189 )
190 );
191 register_meta(
192 'user',
193 OPENSTATION_AGENT_ABILITIES_META,
194 array(
195 'type' => 'string',
196 'single' => true,
197 'default' => '',
198 'show_in_rest' => false,
199 'sanitize_callback' => 'openstation_agent_sanitize_abilities_json',
200 'auth_callback' => $auth,
201 )
202 );
203 register_meta(
204 'user',
205 OPENSTATION_AGENT_TRIGGERS_META,
206 array(
207 'type' => 'string',
208 'single' => true,
209 'default' => '',
210 'show_in_rest' => false,
211 'sanitize_callback' => 'openstation_agent_sanitize_triggers_json',
212 'auth_callback' => $auth,
213 )
214 );
215 register_meta(
216 'user',
217 OPENSTATION_AGENT_MODEL_META,
218 array(
219 'type' => 'string',
220 'single' => true,
221 'default' => '',
222 'show_in_rest' => false,
223 'sanitize_callback' => 'sanitize_text_field',
224 'auth_callback' => $auth,
225 )
226 );
227 register_meta(
228 'user',
229 OPENSTATION_AGENT_RATE_LIMIT_META,
230 array(
231 'type' => 'integer',
232 'single' => true,
233 'default' => 0,
234 'show_in_rest' => false,
235 'sanitize_callback' => 'absint',
236 'auth_callback' => $auth,
237 )
238 );
239 register_meta(
240 'user',
241 OPENSTATION_AGENT_VIBES_META,
242 array(
243 'type' => 'string',
244 'single' => true,
245 'default' => '',
246 'show_in_rest' => false,
247 'sanitize_callback' => 'openstation_agent_sanitize_vibes',
248 'auth_callback' => $auth,
249 )
250 );
251 register_meta(
252 'user',
253 OPENSTATION_AGENT_FACE_META,
254 array(
255 'type' => 'string',
256 'single' => true,
257 'default' => '',
258 'show_in_rest' => false,
259 'sanitize_callback' => 'openstation_agent_sanitize_face_json',
260 'auth_callback' => $auth,
261 )
262 );
263 register_meta(
264 'user',
265 OPENSTATION_AGENT_FACE_SEED_META,
266 array(
267 'type' => 'integer',
268 'single' => true,
269 'default' => 0,
270 'show_in_rest' => false,
271 'sanitize_callback' => 'absint',
272 'auth_callback' => $auth,
273 )
274 );
275 }
276 add_action( 'init', 'openstation_agents_register_user_meta' );
277
278 // ---------------------------------------------------------------------------
279 // Sanitizers
280 // ---------------------------------------------------------------------------
281
282 /**
283 * Normalize an ability-slug list: strings only, trimmed, deduped.
284 *
285 * @param mixed $value Incoming list.
286 * @return string[]
287 */
288 function openstation_agents_sanitize_ability_slugs( $value ) {
289 if ( is_string( $value ) ) {
290 $decoded = json_decode( $value, true );
291 $value = is_array( $decoded ) ? $decoded : array();
292 }
293 if ( ! is_array( $value ) ) {
294 return array();
295 }
296 $out = array();
297 foreach ( $value as $slug ) {
298 if ( ! is_string( $slug ) ) {
299 continue;
300 }
301 $clean = sanitize_text_field( $slug );
302 if ( '' === $clean ) {
303 continue;
304 }
305 $out[] = $clean;
306 }
307 return array_values( array_unique( $out ) );
308 }
309
310 /**
311 * `register_meta` sanitize callback — abilities land on disk as a JSON
312 * string so a read is one meta row and no PHP-serialized arrays exist.
313 *
314 * @param mixed $value Incoming value (array or JSON string).
315 * @return string JSON-encoded slug list.
316 */
317 function openstation_agent_sanitize_abilities_json( $value ) {
318 return (string) wp_json_encode( openstation_agents_sanitize_ability_slugs( $value ) );
319 }
320
321 /**
322 * Sanitize an agent's voice line.
323 *
324 * One short line of character: "blunt, precise, no sugarcoating". It
325 * is appended to the agent's instructions at run time, so it reaches a
326 * language model.
327 *
328 * **That is not a new privilege boundary.** Writing it needs
329 * `edit_users`, the same capability that already lets you write
330 * `instructions`, which is the entire system prompt. A 120-character
331 * tone line is strictly less reach than that, so it deliberately sits
332 * behind the same gate rather than a stricter one, and this note
333 * exists so nobody "hardens" it later into a confusing split.
334 *
335 * Two structural guards it does need. `sanitize_text_field()` strips
336 * line breaks, which is load-bearing: the runner marks operator turns
337 * in the composed prompt, and a multi-line voice line could otherwise
338 * fake a turn boundary. And the length cap keeps a "voice" from
339 * becoming a second instruction block by volume.
340 *
341 * @param mixed $value Incoming line.
342 * @return string
343 */
344 function openstation_agent_sanitize_vibes( $value ) {
345 if ( ! is_scalar( $value ) ) {
346 return '';
347 }
348 $clean = sanitize_text_field( (string) $value );
349 return mb_substr( $clean, 0, OPENSTATION_AGENT_VIBES_MAX_LENGTH );
350 }
351
352 /**
353 * Sanitize an agent's face for storage.
354 *
355 * The narrowing itself is `openstation_mio_narrow_look()`, which the
356 * WP Explorer config also calls to preview the shipped cast while the
357 * feature flag is off. Shared on purpose: two copies of "clamp, then
358 * keep what was carried" is how a preview starts drawing a face the
359 * seeder would never store. What this adds on top is the storage
360 * shape — a JSON string, and an empty one when nothing was set, so an
361 * agent with no opinion keeps no row at all rather than an empty blob.
362 *
363 * @param mixed $value Incoming look (array or JSON string).
364 * @return string JSON, or an empty string when nothing was set.
365 */
366 function openstation_agent_sanitize_face_json( $value ) {
367 if ( is_string( $value ) ) {
368 $decoded = json_decode( $value, true );
369 $value = is_array( $decoded ) ? $decoded : array();
370 }
371 $out = openstation_mio_narrow_look( $value );
372 if ( empty( $out['appearance'] ) && empty( $out['physics'] ) ) {
373 return '';
374 }
375 return (string) wp_json_encode( $out );
376 }
377
378 /**
379 * Read an agent's voice line.
380 *
381 * @param int $user_id Agent user id.
382 * @return string
383 */
384 function openstation_agent_get_vibes( $user_id ) {
385 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_VIBES_META, true );
386 }
387
388 /**
389 * Read an agent's stored face.
390 *
391 * A partial look: only what was overridden. Empty means "no face
392 * chosen", which the avatar resolver reads as the shipped robot.
393 *
394 * @param int $user_id Agent user id.
395 * @return array {
396 * @type array $appearance Partial appearance overrides.
397 * @type array $physics Partial silhouette overrides.
398 * }
399 */
400 function openstation_agent_get_face( $user_id ) {
401 $raw = (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_META, true );
402 if ( '' === $raw ) {
403 return array(
404 'appearance' => array(),
405 'physics' => array(),
406 );
407 }
408 return openstation_sanitize_mio_look( json_decode( $raw, true ) );
409 }
410
411 /**
412 * Read the seed an agent's face was rolled from.
413 *
414 * Kept alongside the face rather than instead of it. The face is what
415 * gets drawn; the seed is provenance, and it is what lets a future
416 * change to the randomizer's ranges re-roll every agent in one
417 * migration instead of stranding them on an old palette.
418 *
419 * @param int $user_id Agent user id.
420 * @return int Seed, or 0 when none was recorded.
421 */
422 function openstation_agent_get_face_seed( $user_id ) {
423 return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_SEED_META, true );
424 }
425
426 /**
427 * Sanitize the triggers array.
428 *
429 * Validates each row against the kind catalogue. Drops any row that
430 * doesn't match a known kind — one bad row never rejects the whole
431 * array.
432 *
433 * @param mixed $value Incoming triggers array (or JSON string).
434 * @return array
435 */
436 function openstation_agent_sanitize_triggers( $value ) {
437 if ( is_string( $value ) ) {
438 $decoded = json_decode( $value, true );
439 $value = is_array( $decoded ) ? $decoded : array();
440 }
441 if ( ! is_array( $value ) ) {
442 return array();
443 }
444
445 $known_kinds = array();
446 foreach ( openstation_agent_trigger_kinds() as $kind ) {
447 $known_kinds[ $kind['slug'] ] = $kind;
448 }
449
450 $out = array();
451 foreach ( $value as $row ) {
452 if ( ! is_array( $row ) ) {
453 continue;
454 }
455 $kind = isset( $row['kind'] ) ? sanitize_key( $row['kind'] ) : '';
456 if ( '' === $kind || ! isset( $known_kinds[ $kind ] ) ) {
457 continue;
458 }
459
460 $config = isset( $row['config'] ) && is_array( $row['config'] ) ? $row['config'] : array();
461 $config = openstation_agent_sanitize_trigger_config_deep( $config );
462
463 $out[] = array(
464 'kind' => $kind,
465 'config' => $config,
466 );
467 }
468
469 return $out;
470 }
471
472 /**
473 * `register_meta` sanitize callback — triggers land on disk as JSON.
474 *
475 * @param mixed $value Incoming value.
476 * @return string JSON-encoded triggers list.
477 */
478 function openstation_agent_sanitize_triggers_json( $value ) {
479 return (string) wp_json_encode( openstation_agent_sanitize_triggers( $value ) );
480 }
481
482 /**
483 * Recursively coerce trigger-config values into safe primitives.
484 *
485 * Keys are camelCase by convention (`entityKinds`, `mimeTypes`,
486 * `fromAgents`) because they round-trip through the JS REST adapter
487 * verbatim — so the case is preserved and only non-identifier
488 * characters are stripped. `sanitize_key()` would lower-case
489 * everything, breaking the contract with the client.
490 *
491 * @param mixed $value Arbitrary input.
492 * @return mixed
493 */
494 function openstation_agent_sanitize_trigger_config_deep( $value ) {
495 if ( is_array( $value ) ) {
496 $out = array();
497 foreach ( $value as $k => $v ) {
498 if ( is_string( $k ) ) {
499 $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', $k );
500 if ( '' === $key ) {
501 continue;
502 }
503 } else {
504 $key = (int) $k;
505 }
506 $out[ $key ] = openstation_agent_sanitize_trigger_config_deep( $v );
507 }
508 return $out;
509 }
510 if ( is_bool( $value ) || is_int( $value ) ) {
511 return $value;
512 }
513 if ( is_numeric( $value ) ) {
514 return $value + 0;
515 }
516 if ( is_string( $value ) ) {
517 return sanitize_text_field( $value );
518 }
519 return null;
520 }
521
522 // ---------------------------------------------------------------------------
523 // Catalogues
524 // ---------------------------------------------------------------------------
525
526 /**
527 * Built-in trigger kinds.
528 *
529 * `chat`, `send-to`, and `drag` are wired; the other kinds are declared so the
530 * Triggers pane can already store configuration for them, and later
531 * phases add the intake plumbing without a storage migration.
532 *
533 * Plugins can extend the list via the `openstation_agent_trigger_kinds`
534 * filter — each entry must declare a `slug`, `label`, and a JSON-Schema
535 * `config_schema` describing the shape of `trigger.config`.
536 *
537 * @return array<int, array{slug:string,wired:bool,label:string,description:string,icon:string,config_schema:array}>
538 */
539 function openstation_agent_trigger_kinds() {
540 $kinds = array(
541 array(
542 'slug' => 'chat',
543 'wired' => true,
544 'label' => __( 'Chat', 'desktop-mode' ),
545 'description' => __( 'Open a conversation window with the agent.', 'desktop-mode' ),
546 'icon' => 'dashicons-format-chat',
547 'config_schema' => array(
548 'type' => 'object',
549 'properties' => array(
550 'capability' => array( 'type' => 'string' ),
551 ),
552 ),
553 ),
554 array(
555 'slug' => 'send-to',
556 'wired' => true,
557 'label' => __( 'Send to (right-click menu)', 'desktop-mode' ),
558 'description' => __( 'The agent appears as a "Send to…" action in the right-click menu for the entity kinds you pick.', 'desktop-mode' ),
559 'icon' => 'dashicons-share-alt',
560 'config_schema' => array(
561 'type' => 'object',
562 'properties' => array(
563 'entityKinds' => array(
564 'type' => 'array',
565 'items' => array(
566 'type' => 'string',
567 'enum' => array( 'post', 'page', 'media', 'user', 'comment' ),
568 ),
569 ),
570 ),
571 ),
572 ),
573 array(
574 'slug' => 'drag',
575 'wired' => true,
576 'label' => __( 'Drag & drop', 'desktop-mode' ),
577 'description' => __( 'Drop a tile onto the agent.', 'desktop-mode' ),
578 'icon' => 'dashicons-move',
579 'config_schema' => array(
580 'type' => 'object',
581 'properties' => array(
582 'mimeTypes' => array(
583 'type' => 'array',
584 'items' => array( 'type' => 'string' ),
585 ),
586 'entityKinds' => array(
587 'type' => 'array',
588 'items' => array( 'type' => 'string' ),
589 ),
590 ),
591 ),
592 ),
593 array(
594 'slug' => 'hook',
595 'wired' => false,
596 'label' => __( 'WordPress hook', 'desktop-mode' ),
597 'description' => __( 'Run automatically when a WordPress action fires.', 'desktop-mode' ),
598 'icon' => 'dashicons-admin-plugins',
599 'config_schema' => array(
600 'type' => 'object',
601 'properties' => array(
602 'hook' => array( 'type' => 'string' ),
603 'priority' => array( 'type' => 'integer' ),
604 ),
605 'required' => array( 'hook' ),
606 ),
607 ),
608 array(
609 'slug' => 'endpoint',
610 'wired' => false,
611 'label' => __( 'REST endpoint', 'desktop-mode' ),
612 'description' => __( 'Expose a REST URL for external services to call.', 'desktop-mode' ),
613 'icon' => 'dashicons-rest-api',
614 'config_schema' => array(
615 'type' => 'object',
616 'properties' => array(
617 'auth' => array(
618 'type' => 'string',
619 'enum' => array( 'capability', 'application-password' ),
620 ),
621 'capability' => array( 'type' => 'string' ),
622 ),
623 ),
624 ),
625 array(
626 'slug' => 'agent',
627 'wired' => false,
628 'label' => __( 'Agent-to-agent', 'desktop-mode' ),
629 'description' => __( 'Run when another agent on this site emits a completion event.', 'desktop-mode' ),
630 'icon' => 'dashicons-networking',
631 'config_schema' => array(
632 'type' => 'object',
633 'properties' => array(
634 'fromAgents' => array(
635 'type' => 'array',
636 'items' => array( 'type' => 'string' ),
637 ),
638 ),
639 ),
640 ),
641 );
642
643 /**
644 * Filter the trigger kinds available to agents.
645 *
646 * @param array $kinds Default trigger kinds.
647 */
648 $filtered = apply_filters( 'openstation_agent_trigger_kinds', $kinds );
649 if ( ! is_array( $filtered ) ) {
650 return $kinds;
651 }
652 return array_values( $filtered );
653 }
654
655 /**
656 * Curated catalogue of WordPress hooks suggested for the Hook trigger.
657 *
658 * Not exhaustive — just the ones agents are most likely to subscribe
659 * to. The renderer offers it as an autocomplete; the user can type any
660 * hook name.
661 *
662 * @return array<int, array{hook:string, when:string}>
663 */
664 function openstation_agent_hooks_catalogue() {
665 $hooks = array(
666 array(
667 'hook' => 'save_post',
668 'when' => __( 'Every time a post is saved.', 'desktop-mode' ),
669 ),
670 array(
671 'hook' => 'wp_insert_post',
672 'when' => __( 'A new post is inserted.', 'desktop-mode' ),
673 ),
674 array(
675 'hook' => 'transition_post_status',
676 'when' => __( 'A post status changes.', 'desktop-mode' ),
677 ),
678 array(
679 'hook' => 'wp_insert_comment',
680 'when' => __( 'A new comment is inserted.', 'desktop-mode' ),
681 ),
682 array(
683 'hook' => 'comment_post',
684 'when' => __( 'A new comment is posted.', 'desktop-mode' ),
685 ),
686 array(
687 'hook' => 'user_register',
688 'when' => __( 'A new user registers.', 'desktop-mode' ),
689 ),
690 array(
691 'hook' => 'profile_update',
692 'when' => __( 'A user profile is updated.', 'desktop-mode' ),
693 ),
694 array(
695 'hook' => 'add_attachment',
696 'when' => __( 'A new attachment is added.', 'desktop-mode' ),
697 ),
698 );
699
700 /**
701 * Filter the curated catalogue of suggested hooks for the Hook
702 * trigger configurator.
703 *
704 * @param array $hooks Default catalogue.
705 */
706 $filtered = apply_filters( 'openstation_agent_hooks_catalogue', $hooks );
707 return is_array( $filtered ) ? array_values( $filtered ) : $hooks;
708 }
709
710 /**
711 * Whether the acting user may grant `$role` to an agent.
712 *
713 * An agent runs with its role's capabilities, so granting a role IS
714 * granting capability — it has to be gated like the promotion it is.
715 * Three constraints, all of which must hold:
716 *
717 * 1. `promote_users` — the capability wp-admin requires to set anyone's
718 * role. `edit_users` alone is not enough: role plugins hand
719 * `edit_users` to shop-manager-shaped roles routinely.
720 * 2. `get_editable_roles()` — core's extension point for "roles this
721 * install lets you hand out". NOTE this is a site-wide filtered
722 * list, NOT a per-user one: core's implementation is a bare
723 * `apply_filters( 'editable_roles', wp_roles()->roles )` with no
724 * reference to the current user. It is a useful constraint because
725 * plugins like WooCommerce filter it, but on a stock install it
726 * excludes nothing, so it cannot be the only gate.
727 * 3. `administrator` additionally requires the actor to genuinely be
728 * an administrator (super admin on multisite). This is the one that
729 * stops an `edit_users`-capable non-admin minting an agent that
730 * outranks them — the capability the agent would then act with.
731 *
732 * @param string $role Role slug being assigned.
733 * @return bool
734 */
735 function openstation_agent_actor_can_assign_role( $role ) {
736 $role = sanitize_key( (string) $role );
737 $can = current_user_can( 'promote_users' );
738
739 if ( $can && 'administrator' === $role ) {
740 $can = is_multisite()
741 ? is_super_admin()
742 : ( current_user_can( 'manage_options' ) && current_user_can( 'create_users' ) );
743 }
744
745 /**
746 * Filter whether the acting user may assign a role to an agent.
747 *
748 * The seam for automation that legitimately creates agents outside
749 * a request context (an activation routine, WP-CLI, a scheduled
750 * provisioning job), where there is no current user and the default
751 * answer is therefore a hard no.
752 *
753 * Granting a role here grants the capabilities an agent will act
754 * with — widen it only for code paths you control.
755 *
756 * @param bool $can Whether the assignment is allowed.
757 * @param string $role Role slug being assigned.
758 * @param int $user_id Acting user id (0 when there is none).
759 */
760 return (bool) apply_filters(
761 'openstation_agent_actor_can_assign_role',
762 $can,
763 $role,
764 get_current_user_id()
765 );
766 }
767
768 /**
769 * Roles an agent may be assigned, constrained to what the acting user
770 * can actually hand out.
771 *
772 * The whitelist keeps agents in the standard content-role band; each
773 * survivor is then run through
774 * {@see openstation_agent_actor_can_assign_role()}, which is where the
775 * real gating lives.
776 *
777 * @return string[] Role slugs.
778 */
779 function openstation_agent_allowed_roles() {
780 $whitelist = array( 'administrator', 'editor', 'author', 'contributor' );
781
782 /**
783 * Filter the roles an agent may be assigned.
784 *
785 * The result is always intersected with `get_editable_roles()` and
786 * then filtered through the per-role actor check — this filter can
787 * narrow or extend the candidate list, but a role it adds still has
788 * to clear both constraints.
789 *
790 * @param string[] $whitelist Default role slugs.
791 */
792 $whitelist = apply_filters( 'openstation_agent_allowed_roles', $whitelist );
793 if ( ! is_array( $whitelist ) ) {
794 return array();
795 }
796
797 if ( ! function_exists( 'get_editable_roles' ) ) {
798 require_once ABSPATH . 'wp-admin/includes/user.php';
799 }
800 $editable = array_keys( get_editable_roles() );
801
802 $candidates = array_intersect( array_map( 'strval', $whitelist ), $editable );
803
804 $allowed = array();
805 foreach ( $candidates as $role ) {
806 if ( openstation_agent_actor_can_assign_role( $role ) ) {
807 $allowed[] = $role;
808 }
809 }
810
811 return array_values( $allowed );
812 }
813
814 // ---------------------------------------------------------------------------
815 // Getters / setters
816 // ---------------------------------------------------------------------------
817
818 /**
819 * Read the "when to use" description.
820 *
821 * @param int $user_id Agent user id.
822 * @return string
823 */
824 function openstation_agent_get_description( $user_id ) {
825 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_DESCRIPTION_META, true );
826 }
827
828 /**
829 * Read the system prompt.
830 *
831 * @param int $user_id Agent user id.
832 * @return string
833 */
834 function openstation_agent_get_instructions( $user_id ) {
835 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_INSTRUCTIONS_META, true );
836 }
837
838 /**
839 * Read the ability allowlist.
840 *
841 * @param int $user_id Agent user id.
842 * @return string[]
843 */
844 function openstation_agent_get_abilities( $user_id ) {
845 $raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_ABILITIES_META, true );
846 if ( '' === $raw || null === $raw ) {
847 return array();
848 }
849 return openstation_agents_sanitize_ability_slugs( $raw );
850 }
851
852 /**
853 * Read triggers.
854 *
855 * @param int $user_id Agent user id.
856 * @return array
857 */
858 function openstation_agent_get_triggers( $user_id ) {
859 $raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_TRIGGERS_META, true );
860 if ( '' === $raw || null === $raw ) {
861 return array();
862 }
863 return openstation_agent_sanitize_triggers( $raw );
864 }
865
866 /**
867 * Read the model override.
868 *
869 * @param int $user_id Agent user id.
870 * @return string Empty string if not set.
871 */
872 function openstation_agent_get_model( $user_id ) {
873 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_MODEL_META, true );
874 }
875
876 /**
877 * Read the rate limit (invocations per hour).
878 *
879 * @param int $user_id Agent user id.
880 * @return int Zero when no per-agent override is set.
881 */
882 function openstation_agent_get_rate_limit( $user_id ) {
883 return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_RATE_LIMIT_META, true );
884 }
885
886 // ---------------------------------------------------------------------------
887 // Per-agent invocation gate
888 // ---------------------------------------------------------------------------
889
890 /**
891 * The agent's trigger row for a given invocation source, if any.
892 *
893 * Source slugs on the invoke route map 1:1 onto trigger kinds
894 * (`chat`, `drag`, `send-to`).
895 *
896 * @param int $agent_user_id Agent user id.
897 * @param string $source Invocation source slug.
898 * @return array|null Trigger row, or null when the agent declares none
899 * for this source.
900 */
901 function openstation_agent_trigger_for_source( $agent_user_id, $source ) {
902 $source = sanitize_key( (string) $source );
903 foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $trigger ) {
904 if ( isset( $trigger['kind'] ) && $source === $trigger['kind'] ) {
905 return $trigger;
906 }
907 }
908 return null;
909 }
910
911 /**
912 * Whether the current user may invoke THIS agent through THIS source.
913 *
914 * The route-level `openstation_agents_user_can_invoke()` check is
915 * site-wide — it answers "may this user invoke agents at all". This is
916 * the per-agent half: a trigger may declare a `capability` in its
917 * config, and until it is enforced here the field is decorative. The
918 * Triggers pane collects it and the store persists it, so an
919 * administrator restricting an agent to `manage_options` has every
920 * reason to believe it took effect.
921 *
922 * An agent with no trigger for the source, or a trigger that declares
923 * no capability, is left to the route-level check — requiring a
924 * configured trigger would lock out every agent created before triggers
925 * were set up, which is all of them by default.
926 *
927 * @param int $agent_user_id Agent user id.
928 * @param string $source Invocation source slug.
929 * @return bool
930 */
931 function openstation_agent_user_can_invoke_agent( $agent_user_id, $source = 'chat' ) {
932 $trigger = openstation_agent_trigger_for_source( $agent_user_id, $source );
933 $capability = '';
934 if ( is_array( $trigger ) && isset( $trigger['config']['capability'] ) ) {
935 $capability = trim( (string) $trigger['config']['capability'] );
936 }
937
938 $can = '' === $capability || current_user_can( $capability );
939
940 /**
941 * Filter whether the current user may invoke a specific agent.
942 *
943 * @param bool $can Whether invocation is allowed.
944 * @param int $agent_user_id Agent user id.
945 * @param string $source Invocation source slug.
946 * @param array|null $trigger The matching trigger row, if any.
947 */
948 return (bool) apply_filters(
949 'openstation_agent_user_can_invoke_agent',
950 $can,
951 (int) $agent_user_id,
952 (string) $source,
953 $trigger
954 );
955 }
956
957 // ---------------------------------------------------------------------------
958 // List helper
959 // ---------------------------------------------------------------------------
960
961 /**
962 * Every agent on the site, ordered by display name.
963 *
964 * @param array $args Optional overrides merged into the `get_users()` query.
965 * @return WP_User[]
966 */
967 function openstation_agent_get_agents( $args = array() ) {
968 $defaults = array(
969 'meta_key' => OPENSTATION_AGENT_USER_MARKER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
970 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
971 'orderby' => 'display_name',
972 'order' => 'ASC',
973 'number' => 200,
974 );
975 return get_users( array_merge( $defaults, is_array( $args ) ? $args : array() ) );
976 }
977
978 // ---------------------------------------------------------------------------
979 // Orchestrators — the only write paths, each firing one audit action
980 // ---------------------------------------------------------------------------
981
982 /**
983 * Create an agent: synthetic user row + definition meta.
984 *
985 * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array, triggers?:array, vibes?:string, face?:array|string, faceSeed?:int} $args Creation args.
986 * @return WP_User|WP_Error
987 */
988 function openstation_agent_create( $args ) {
989 $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : '';
990 $allowed = openstation_agent_allowed_roles();
991 if ( '' === $role || ! in_array( $role, $allowed, true ) ) {
992 return new WP_Error(
993 'openstation_agent_invalid_role',
994 __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
995 );
996 }
997
998 $user = openstation_agent_create_user( $args );
999 if ( is_wp_error( $user ) ) {
1000 return $user;
1001 }
1002
1003 $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : '';
1004 $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : '';
1005 $abilities = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array();
1006 $triggers = isset( $args['triggers'] ) ? openstation_agent_sanitize_triggers( $args['triggers'] ) : array();
1007 $vibes = isset( $args['vibes'] ) ? openstation_agent_sanitize_vibes( $args['vibes'] ) : '';
1008 $face = isset( $args['face'] ) ? openstation_agent_sanitize_face_json( $args['face'] ) : '';
1009 // Every agent gets a seed even when nobody chose a face, so the
1010 // backfill has something deterministic to roll from and two admins
1011 // racing it land on the same portrait. `crc32` of the login is
1012 // stable, cheap, and already unique per agent.
1013 $seed = isset( $args['faceSeed'] ) ? absint( $args['faceSeed'] ) : 0;
1014 if ( 0 === $seed ) {
1015 $seed = crc32( (string) $user->user_login );
1016 }
1017
1018 if ( '' !== $description ) {
1019 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
1020 }
1021 if ( '' !== $instructions ) {
1022 update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
1023 }
1024 if ( ! empty( $abilities ) ) {
1025 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
1026 }
1027 if ( ! empty( $triggers ) ) {
1028 update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
1029 }
1030 if ( '' !== $vibes ) {
1031 update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1032 }
1033 if ( '' !== $face ) {
1034 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1035 }
1036 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
1037 update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() );
1038
1039 /**
1040 * Fires after an agent is created.
1041 *
1042 * @param int $user_id Agent user id.
1043 * @param array $args Sanitized creation fields (name, role,
1044 * description, instructions, abilities).
1045 * @param int $actor_id User who created the agent.
1046 */
1047 do_action(
1048 'openstation_agent_created',
1049 (int) $user->ID,
1050 array(
1051 'name' => (string) $user->display_name,
1052 'role' => $role,
1053 'description' => $description,
1054 'instructions' => $instructions,
1055 'abilities' => $abilities,
1056 'vibes' => $vibes,
1057 'face' => $face,
1058 'faceSeed' => $seed,
1059 ),
1060 get_current_user_id()
1061 );
1062
1063 return $user;
1064 }
1065
1066 /**
1067 * Update an agent's definition. Accepts any subset of the recognized
1068 * fields, applies the valid ones, and fires `openstation_agent_updated`
1069 * once with a before/after map of everything that changed.
1070 *
1071 * Recognized fields: `name`, `role`, `description`, `instructions`,
1072 * `abilities`, `triggers`, `model`, `rateLimit`, `vibes`, `face`,
1073 * `faceSeed`.
1074 *
1075 * @param int $user_id Agent user id.
1076 * @param array $fields Field map.
1077 * @return true|WP_Error
1078 */
1079 function openstation_agent_update( $user_id, array $fields ) {
1080 $user = get_userdata( (int) $user_id );
1081 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
1082 return new WP_Error(
1083 'openstation_agent_not_found',
1084 __( 'Agent not found.', 'desktop-mode' )
1085 );
1086 }
1087
1088 $changed = array();
1089
1090 if ( isset( $fields['name'] ) ) {
1091 $name = sanitize_text_field( (string) $fields['name'] );
1092 if ( '' === $name ) {
1093 return new WP_Error(
1094 'openstation_agent_invalid_name',
1095 __( 'Agent name cannot be empty.', 'desktop-mode' )
1096 );
1097 }
1098 if ( $name !== (string) $user->display_name ) {
1099 $changed['name'] = array(
1100 'from' => (string) $user->display_name,
1101 'to' => $name,
1102 );
1103 wp_update_user(
1104 array(
1105 'ID' => (int) $user->ID,
1106 'display_name' => $name,
1107 'nickname' => $name,
1108 )
1109 );
1110 }
1111 }
1112
1113 if ( isset( $fields['role'] ) ) {
1114 $role = sanitize_key( (string) $fields['role'] );
1115 if ( ! in_array( $role, openstation_agent_allowed_roles(), true ) ) {
1116 return new WP_Error(
1117 'openstation_agent_invalid_role',
1118 __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
1119 );
1120 }
1121 $current_role = is_array( $user->roles ) && ! empty( $user->roles ) ? (string) reset( $user->roles ) : '';
1122 if ( $role !== $current_role ) {
1123 $changed['role'] = array(
1124 'from' => $current_role,
1125 'to' => $role,
1126 );
1127 $user->set_role( $role );
1128 }
1129 }
1130
1131 if ( isset( $fields['description'] ) ) {
1132 $description = sanitize_text_field( (string) $fields['description'] );
1133 $before = openstation_agent_get_description( $user->ID );
1134 if ( $description !== $before ) {
1135 $changed['description'] = array(
1136 'from' => $before,
1137 'to' => $description,
1138 );
1139 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
1140 }
1141 }
1142
1143 if ( isset( $fields['instructions'] ) ) {
1144 $instructions = wp_kses_post( (string) $fields['instructions'] );
1145 $before = openstation_agent_get_instructions( $user->ID );
1146 if ( $instructions !== $before ) {
1147 $changed['instructions'] = array(
1148 'from' => $before,
1149 'to' => $instructions,
1150 );
1151 update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
1152 }
1153 }
1154
1155 if ( isset( $fields['abilities'] ) ) {
1156 $abilities = openstation_agents_sanitize_ability_slugs( $fields['abilities'] );
1157 $before = openstation_agent_get_abilities( $user->ID );
1158 if ( $abilities !== $before ) {
1159 $changed['abilities'] = array(
1160 'from' => $before,
1161 'to' => $abilities,
1162 );
1163 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
1164 }
1165 }
1166
1167 if ( isset( $fields['triggers'] ) ) {
1168 $triggers = openstation_agent_sanitize_triggers( $fields['triggers'] );
1169 $before = openstation_agent_get_triggers( $user->ID );
1170 if ( $triggers !== $before ) {
1171 $changed['triggers'] = array(
1172 'from' => $before,
1173 'to' => $triggers,
1174 );
1175 update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
1176 }
1177 }
1178
1179 if ( isset( $fields['model'] ) ) {
1180 $model = sanitize_text_field( (string) $fields['model'] );
1181 $before = openstation_agent_get_model( $user->ID );
1182 if ( $model !== $before ) {
1183 $changed['model'] = array(
1184 'from' => $before,
1185 'to' => $model,
1186 );
1187 if ( '' === $model ) {
1188 delete_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META );
1189 } else {
1190 update_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META, $model );
1191 }
1192 }
1193 }
1194
1195 if ( isset( $fields['rateLimit'] ) ) {
1196 $rate = max( 0, (int) $fields['rateLimit'] );
1197 $before = openstation_agent_get_rate_limit( $user->ID );
1198 if ( $rate !== $before ) {
1199 $changed['rateLimit'] = array(
1200 'from' => $before,
1201 'to' => $rate,
1202 );
1203 if ( 0 === $rate ) {
1204 delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META );
1205 } else {
1206 update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate );
1207 }
1208 }
1209 }
1210
1211 if ( isset( $fields['vibes'] ) ) {
1212 $vibes = openstation_agent_sanitize_vibes( $fields['vibes'] );
1213 $before = openstation_agent_get_vibes( $user->ID );
1214 if ( $vibes !== $before ) {
1215 $changed['vibes'] = array(
1216 'from' => $before,
1217 'to' => $vibes,
1218 );
1219 if ( '' === $vibes ) {
1220 delete_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META );
1221 } else {
1222 update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1223 }
1224 }
1225 }
1226
1227 if ( isset( $fields['face'] ) ) {
1228 $face = openstation_agent_sanitize_face_json( $fields['face'] );
1229 $before = (string) get_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, true );
1230 if ( $face !== $before ) {
1231 $changed['face'] = array(
1232 'from' => $before,
1233 'to' => $face,
1234 );
1235 if ( '' === $face ) {
1236 delete_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META );
1237 } else {
1238 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1239 }
1240 }
1241 }
1242
1243 if ( isset( $fields['faceSeed'] ) ) {
1244 $seed = absint( $fields['faceSeed'] );
1245 $before = openstation_agent_get_face_seed( $user->ID );
1246 if ( $seed !== $before ) {
1247 $changed['faceSeed'] = array(
1248 'from' => $before,
1249 'to' => $seed,
1250 );
1251 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
1252 }
1253 }
1254
1255 if ( ! empty( $changed ) ) {
1256 /**
1257 * Fires after an agent's definition changed.
1258 *
1259 * User meta has no revisions, so this action IS the audit
1260 * trail — each changed field carries its before/after value.
1261 *
1262 * @param int $user_id Agent user id.
1263 * @param array $changed Map of field => { from, to }.
1264 * @param int $actor_id User who made the change.
1265 */
1266 do_action( 'openstation_agent_updated', (int) $user->ID, $changed, get_current_user_id() );
1267 }
1268
1269 return true;
1270 }
1271