PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 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 All 36 releases
desktop-mode / includes / agents / store.php

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

1,299 lines 42.6 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`). The row is context for the invocation
895 * filter; it does not decide which capabilities the invocation gate
896 * requires, which is every capability on every trigger (see
897 * `openstation_agent_user_can_invoke_agent()`).
898 *
899 * @param int $agent_user_id Agent user id.
900 * @param string $source Invocation source slug.
901 * @return array|null Trigger row, or null when the agent declares none
902 * for this source.
903 */
904 function openstation_agent_trigger_for_source( $agent_user_id, $source ) {
905 $source = sanitize_key( (string) $source );
906 foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $trigger ) {
907 if ( isset( $trigger['kind'] ) && $source === $trigger['kind'] ) {
908 return $trigger;
909 }
910 }
911 return null;
912 }
913
914 /**
915 * Whether the current user may invoke THIS agent.
916 *
917 * The route-level `openstation_agents_user_can_invoke()` check is
918 * site-wide — it answers "may this user invoke agents at all". This is
919 * the per-agent half: a trigger may declare a `capability` in its
920 * config, and the Triggers pane collects it and the store persists it,
921 * so an administrator restricting an agent to `manage_options` has
922 * every reason to believe it took effect.
923 *
924 * The caller must hold EVERY capability declared on ANY of the agent's
925 * triggers, whichever source the request names. The source is supplied
926 * by the client (the invoke route takes it as a request parameter), so
927 * it describes how the request says it arrived, not what it is allowed
928 * to reach: a capability scoped to one trigger kind would be satisfied
929 * by naming another. A capability configured on an agent is therefore
930 * a property of the agent.
931 *
932 * An agent whose triggers declare no capability (including one with no
933 * triggers at all) is left to the route-level check — requiring a
934 * configured trigger would lock out every agent created before triggers
935 * were set up, which is all of them by default.
936 *
937 * @param int $agent_user_id Agent user id.
938 * @param string $source Invocation source slug the request names
939 * (`chat`, `drag`, `send-to`). Context for
940 * the filter only; it does not select which
941 * capabilities apply.
942 * @return bool
943 */
944 function openstation_agent_user_can_invoke_agent( $agent_user_id, $source = 'chat' ) {
945 $can = true;
946 foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $row ) {
947 if ( ! isset( $row['config']['capability'] ) || ! is_scalar( $row['config']['capability'] ) ) {
948 continue;
949 }
950 $capability = trim( (string) $row['config']['capability'] );
951 if ( '' !== $capability && ! current_user_can( $capability ) ) {
952 $can = false;
953 break;
954 }
955 }
956
957 /**
958 * Filter whether the current user may invoke a specific agent.
959 *
960 * @param bool $can Whether invocation is allowed: the
961 * caller holds every capability
962 * declared on any of the agent's
963 * triggers.
964 * @param int $agent_user_id Agent user id.
965 * @param string $source Invocation source slug the request
966 * names. Client-supplied on the invoke
967 * route, so context rather than proof
968 * of how the request arrived.
969 * @param array|null $trigger The trigger row whose kind matches
970 * `$source`, if any. Context only: it
971 * is not what decided `$can`.
972 */
973 return (bool) apply_filters(
974 'openstation_agent_user_can_invoke_agent',
975 $can,
976 (int) $agent_user_id,
977 (string) $source,
978 openstation_agent_trigger_for_source( $agent_user_id, $source )
979 );
980 }
981
982 // ---------------------------------------------------------------------------
983 // List helper
984 // ---------------------------------------------------------------------------
985
986 /**
987 * Every agent on the site, ordered by display name.
988 *
989 * @param array $args Optional overrides merged into the `get_users()` query.
990 * @return WP_User[]
991 */
992 function openstation_agent_get_agents( $args = array() ) {
993 $defaults = array(
994 'meta_key' => OPENSTATION_AGENT_USER_MARKER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
995 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
996 'orderby' => 'display_name',
997 'order' => 'ASC',
998 'number' => 200,
999 );
1000 return get_users( array_merge( $defaults, is_array( $args ) ? $args : array() ) );
1001 }
1002
1003 // ---------------------------------------------------------------------------
1004 // Orchestrators — the only write paths, each firing one audit action
1005 // ---------------------------------------------------------------------------
1006
1007 /**
1008 * Create an agent: synthetic user row + definition meta.
1009 *
1010 * @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.
1011 * @return WP_User|WP_Error
1012 */
1013 function openstation_agent_create( $args ) {
1014 $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : '';
1015 $allowed = openstation_agent_allowed_roles();
1016 if ( '' === $role || ! in_array( $role, $allowed, true ) ) {
1017 return new WP_Error(
1018 'openstation_agent_invalid_role',
1019 __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
1020 );
1021 }
1022
1023 $user = openstation_agent_create_user( $args );
1024 if ( is_wp_error( $user ) ) {
1025 return $user;
1026 }
1027
1028 $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : '';
1029 $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : '';
1030 $abilities = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array();
1031 $triggers = isset( $args['triggers'] ) ? openstation_agent_sanitize_triggers( $args['triggers'] ) : array();
1032 $vibes = isset( $args['vibes'] ) ? openstation_agent_sanitize_vibes( $args['vibes'] ) : '';
1033 $face = isset( $args['face'] ) ? openstation_agent_sanitize_face_json( $args['face'] ) : '';
1034 // Every agent gets a seed even when nobody chose a face, so the
1035 // backfill has something deterministic to roll from and two admins
1036 // racing it land on the same portrait. `crc32` of the login is
1037 // stable, cheap, and already unique per agent.
1038 $seed = isset( $args['faceSeed'] ) ? absint( $args['faceSeed'] ) : 0;
1039 if ( 0 === $seed ) {
1040 $seed = crc32( (string) $user->user_login );
1041 }
1042
1043 if ( '' !== $description ) {
1044 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
1045 }
1046 if ( '' !== $instructions ) {
1047 update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
1048 }
1049 if ( ! empty( $abilities ) ) {
1050 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
1051 }
1052 if ( ! empty( $triggers ) ) {
1053 update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
1054 }
1055 if ( '' !== $vibes ) {
1056 update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1057 }
1058 if ( '' !== $face ) {
1059 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1060 }
1061 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
1062 update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() );
1063
1064 /**
1065 * Fires after an agent is created.
1066 *
1067 * @param int $user_id Agent user id.
1068 * @param array $args Sanitized creation fields (name, role,
1069 * description, instructions, abilities).
1070 * @param int $actor_id User who created the agent.
1071 */
1072 do_action(
1073 'openstation_agent_created',
1074 (int) $user->ID,
1075 array(
1076 'name' => (string) $user->display_name,
1077 'role' => $role,
1078 'description' => $description,
1079 'instructions' => $instructions,
1080 'abilities' => $abilities,
1081 'vibes' => $vibes,
1082 'face' => $face,
1083 'faceSeed' => $seed,
1084 ),
1085 get_current_user_id()
1086 );
1087
1088 return $user;
1089 }
1090
1091 /**
1092 * Update an agent's definition. Accepts any subset of the recognized
1093 * fields, applies the valid ones, and fires `openstation_agent_updated`
1094 * once with a before/after map of everything that changed.
1095 *
1096 * Recognized fields: `name`, `role`, `description`, `instructions`,
1097 * `abilities`, `triggers`, `model`, `rateLimit`, `vibes`, `face`,
1098 * `faceSeed`.
1099 *
1100 * @param int $user_id Agent user id.
1101 * @param array $fields Field map.
1102 * @return true|WP_Error
1103 */
1104 function openstation_agent_update( $user_id, array $fields ) {
1105 $user = get_userdata( (int) $user_id );
1106 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
1107 return new WP_Error(
1108 'openstation_agent_not_found',
1109 __( 'Agent not found.', 'desktop-mode' )
1110 );
1111 }
1112
1113 $changed = array();
1114
1115 if ( isset( $fields['name'] ) ) {
1116 $name = sanitize_text_field( (string) $fields['name'] );
1117 if ( '' === $name ) {
1118 return new WP_Error(
1119 'openstation_agent_invalid_name',
1120 __( 'Agent name cannot be empty.', 'desktop-mode' )
1121 );
1122 }
1123 // Compared as plain text: the stored name carries entities
1124 // (`&amp;`), and the client sends back the decoded one it was
1125 // given on every save. Raw, that reads as a rename.
1126 if ( openstation_plain_text_title( $name ) !== openstation_plain_text_title( $user->display_name ) ) {
1127 $changed['name'] = array(
1128 'from' => (string) $user->display_name,
1129 'to' => $name,
1130 );
1131 wp_update_user(
1132 array(
1133 'ID' => (int) $user->ID,
1134 'display_name' => $name,
1135 'nickname' => $name,
1136 )
1137 );
1138 }
1139 }
1140
1141 if ( isset( $fields['role'] ) ) {
1142 $role = sanitize_key( (string) $fields['role'] );
1143 if ( ! in_array( $role, openstation_agent_allowed_roles(), true ) ) {
1144 return new WP_Error(
1145 'openstation_agent_invalid_role',
1146 __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
1147 );
1148 }
1149 $current_role = is_array( $user->roles ) && ! empty( $user->roles ) ? (string) reset( $user->roles ) : '';
1150 if ( $role !== $current_role ) {
1151 $changed['role'] = array(
1152 'from' => $current_role,
1153 'to' => $role,
1154 );
1155 $user->set_role( $role );
1156 }
1157 }
1158
1159 if ( isset( $fields['description'] ) ) {
1160 $description = sanitize_text_field( (string) $fields['description'] );
1161 $before = openstation_agent_get_description( $user->ID );
1162 if ( $description !== $before ) {
1163 $changed['description'] = array(
1164 'from' => $before,
1165 'to' => $description,
1166 );
1167 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
1168 }
1169 }
1170
1171 if ( isset( $fields['instructions'] ) ) {
1172 $instructions = wp_kses_post( (string) $fields['instructions'] );
1173 $before = openstation_agent_get_instructions( $user->ID );
1174 if ( $instructions !== $before ) {
1175 $changed['instructions'] = array(
1176 'from' => $before,
1177 'to' => $instructions,
1178 );
1179 update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
1180 }
1181 }
1182
1183 if ( isset( $fields['abilities'] ) ) {
1184 $abilities = openstation_agents_sanitize_ability_slugs( $fields['abilities'] );
1185 $before = openstation_agent_get_abilities( $user->ID );
1186 if ( $abilities !== $before ) {
1187 $changed['abilities'] = array(
1188 'from' => $before,
1189 'to' => $abilities,
1190 );
1191 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
1192 }
1193 }
1194
1195 if ( isset( $fields['triggers'] ) ) {
1196 $triggers = openstation_agent_sanitize_triggers( $fields['triggers'] );
1197 $before = openstation_agent_get_triggers( $user->ID );
1198 if ( $triggers !== $before ) {
1199 $changed['triggers'] = array(
1200 'from' => $before,
1201 'to' => $triggers,
1202 );
1203 update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
1204 }
1205 }
1206
1207 if ( isset( $fields['model'] ) ) {
1208 $model = sanitize_text_field( (string) $fields['model'] );
1209 $before = openstation_agent_get_model( $user->ID );
1210 if ( $model !== $before ) {
1211 $changed['model'] = array(
1212 'from' => $before,
1213 'to' => $model,
1214 );
1215 if ( '' === $model ) {
1216 delete_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META );
1217 } else {
1218 update_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META, $model );
1219 }
1220 }
1221 }
1222
1223 if ( isset( $fields['rateLimit'] ) ) {
1224 $rate = max( 0, (int) $fields['rateLimit'] );
1225 $before = openstation_agent_get_rate_limit( $user->ID );
1226 if ( $rate !== $before ) {
1227 $changed['rateLimit'] = array(
1228 'from' => $before,
1229 'to' => $rate,
1230 );
1231 if ( 0 === $rate ) {
1232 delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META );
1233 } else {
1234 update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate );
1235 }
1236 }
1237 }
1238
1239 if ( isset( $fields['vibes'] ) ) {
1240 $vibes = openstation_agent_sanitize_vibes( $fields['vibes'] );
1241 $before = openstation_agent_get_vibes( $user->ID );
1242 if ( $vibes !== $before ) {
1243 $changed['vibes'] = array(
1244 'from' => $before,
1245 'to' => $vibes,
1246 );
1247 if ( '' === $vibes ) {
1248 delete_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META );
1249 } else {
1250 update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1251 }
1252 }
1253 }
1254
1255 if ( isset( $fields['face'] ) ) {
1256 $face = openstation_agent_sanitize_face_json( $fields['face'] );
1257 $before = (string) get_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, true );
1258 if ( $face !== $before ) {
1259 $changed['face'] = array(
1260 'from' => $before,
1261 'to' => $face,
1262 );
1263 if ( '' === $face ) {
1264 delete_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META );
1265 } else {
1266 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1267 }
1268 }
1269 }
1270
1271 if ( isset( $fields['faceSeed'] ) ) {
1272 $seed = absint( $fields['faceSeed'] );
1273 $before = openstation_agent_get_face_seed( $user->ID );
1274 if ( $seed !== $before ) {
1275 $changed['faceSeed'] = array(
1276 'from' => $before,
1277 'to' => $seed,
1278 );
1279 update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
1280 }
1281 }
1282
1283 if ( ! empty( $changed ) ) {
1284 /**
1285 * Fires after an agent's definition changed.
1286 *
1287 * User meta has no revisions, so this action IS the audit
1288 * trail — each changed field carries its before/after value.
1289 *
1290 * @param int $user_id Agent user id.
1291 * @param array $changed Map of field => { from, to }.
1292 * @param int $actor_id User who made the change.
1293 */
1294 do_action( 'openstation_agent_updated', (int) $user->ID, $changed, get_current_user_id() );
1295 }
1296
1297 return true;
1298 }
1299