PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 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 All 37 releases
← All changes | includes/agents/store.php +330 -22 1.1.1 → 1.1.13 View file →
@@ -94,10 +94,42 @@
94 94 * URL). The mismatch between this constant's name and its value is
95 95 * deliberate — it is NOT a half-finished rename.
96 96 */
97 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';
98 106
99 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 +/**
100 132 * Every meta key the store writes — the privacy eraser and any future
101 133 * cleanup path iterate this list instead of re-typing the constants.
102 134 *
103 135 * @return string[]
@@ -111,8 +143,11 @@
111 143 OPENSTATION_AGENT_TRIGGERS_META,
112 144 OPENSTATION_AGENT_MODEL_META,
113 145 OPENSTATION_AGENT_RATE_LIMIT_META,
114 146 OPENSTATION_AGENT_CREATED_BY_META,
147 + OPENSTATION_AGENT_VIBES_META,
148 + OPENSTATION_AGENT_FACE_META,
149 + OPENSTATION_AGENT_FACE_SEED_META,
115 150 );
116 151 }
117 152
118 153 /**
@@ -200,8 +235,44 @@
200 235 'sanitize_callback' => 'absint',
201 236 'auth_callback' => $auth,
202 237 )
203 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 + );
204 275 }
205 276 add_action( 'init', 'openstation_agents_register_user_meta' );
206 277
207 278 // ---------------------------------------------------------------------------
@@ -247,8 +318,113 @@
247 318 return (string) wp_json_encode( openstation_agents_sanitize_ability_slugs( $value ) );
248 319 }
249 320
250 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 +/**
251 427 * Sanitize the triggers array.
252 428 *
253 429 * Validates each row against the kind catalogue. Drops any row that
254 430 * doesn't match a known kind — one bad row never rejects the whole
@@ -714,9 +890,12 @@
714 890 /**
715 891 * The agent's trigger row for a given invocation source, if any.
716 892 *
717 893 * Source slugs on the invoke route map 1:1 onto trigger kinds
718 - * (`chat`, `drag`, `send-to`).
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()`).
719 898 *
720 899 * @param int $agent_user_id Agent user id.
721 900 * @param string $source Invocation source slug.
722 901 * @return array|null Trigger row, or null when the agent declares none
@@ -732,43 +911,65 @@
732 911 return null;
733 912 }
734 913
735 914 /**
736 - * Whether the current user may invoke THIS agent through THIS source.
915 + * Whether the current user may invoke THIS agent.
737 916 *
738 917 * The route-level `openstation_agents_user_can_invoke()` check is
739 918 * site-wide — it answers "may this user invoke agents at all". This is
740 919 * the per-agent half: a trigger may declare a `capability` in its
741 - * config, and until it is enforced here the field is decorative. The
742 - * Triggers pane collects it and the store persists it, so an
743 - * administrator restricting an agent to `manage_options` has every
744 - * reason to believe it took effect.
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.
745 923 *
746 - * An agent with no trigger for the source, or a trigger that declares
747 - * no capability, is left to the route-level check — requiring a
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
748 934 * configured trigger would lock out every agent created before triggers
749 935 * were set up, which is all of them by default.
750 936 *
751 937 * @param int $agent_user_id Agent user id.
752 - * @param string $source Invocation source slug.
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.
753 942 * @return bool
754 943 */
755 944 function openstation_agent_user_can_invoke_agent( $agent_user_id, $source = 'chat' ) {
756 - $trigger = openstation_agent_trigger_for_source( $agent_user_id, $source );
757 - $capability = '';
758 - if ( is_array( $trigger ) && isset( $trigger['config']['capability'] ) ) {
759 - $capability = trim( (string) $trigger['config']['capability'] );
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 + }
760 955 }
761 956
762 - $can = '' === $capability || current_user_can( $capability );
763 -
764 957 /**
765 958 * Filter whether the current user may invoke a specific agent.
766 959 *
767 - * @param bool $can Whether invocation is allowed.
960 + * @param bool $can Whether invocation is allowed: the
961 + * caller holds every capability
962 + * declared on any of the agent's
963 + * triggers.
768 964 * @param int $agent_user_id Agent user id.
769 - * @param string $source Invocation source slug.
770 - * @param array|null $trigger The matching trigger row, if any.
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`.
771 972 */
772 973 return (bool) apply_filters(
773 974 'openstation_agent_user_can_invoke_agent',
774 975 $can,
@@ -773,9 +974,9 @@
773 974 'openstation_agent_user_can_invoke_agent',
774 975 $can,
775 976 (int) $agent_user_id,
776 977 (string) $source,
777 - $trigger
978 + openstation_agent_trigger_for_source( $agent_user_id, $source )
778 979 );
779 980 }
780 981
781 982 // ---------------------------------------------------------------------------
@@ -798,8 +999,43 @@
798 999 );
799 1000 return get_users( array_merge( $defaults, is_array( $args ) ? $args : array() ) );
800 1001 }
801 1002
1003 +/**
1004 + * The agents the current user can hand an entity to with "Send to".
1005 + *
1006 + * Every agent declaring a `send-to` trigger that this user may invoke,
1007 + * with the entity kinds the trigger accepts (empty = every kind). The
1008 + * shell inlines it so desktop and folder tile menus can offer
1009 + * "Send to <agent>" synchronously, the way WP Explorer's menu does.
1010 + * Empty while the framework is off or the user may not invoke agents.
1011 + *
1012 + * @return array<int, array{id: int, name: string, description: string, avatarUrl: string, entityKinds: string[]}>
1013 + */
1014 +function openstation_agents_send_to_targets() {
1015 + if ( ! openstation_agents_enabled() || ! openstation_agents_user_can_invoke() ) {
1016 + return array();
1017 + }
1018 + $targets = array();
1019 + foreach ( openstation_agent_get_agents() as $user ) {
1020 + $trigger = openstation_agent_trigger_for_source( (int) $user->ID, 'send-to' );
1021 + if ( null === $trigger || ! openstation_agent_user_can_invoke_agent( (int) $user->ID, 'send-to' ) ) {
1022 + continue;
1023 + }
1024 + $kinds = isset( $trigger['config']['entityKinds'] ) && is_array( $trigger['config']['entityKinds'] )
1025 + ? array_values( array_map( 'strval', $trigger['config']['entityKinds'] ) )
1026 + : array();
1027 + $targets[] = array(
1028 + 'id' => (int) $user->ID,
1029 + 'name' => openstation_plain_text_title( $user->display_name ),
1030 + 'description' => openstation_agent_get_description( (int) $user->ID ),
1031 + 'avatarUrl' => openstation_agent_avatar_url( (int) $user->ID ),
1032 + 'entityKinds' => $kinds,
1033 + );
1034 + }
1035 + return $targets;
1036 +}
1037 +
802 1038 // ---------------------------------------------------------------------------
803 1039 // Orchestrators — the only write paths, each firing one audit action
804 1040 // ---------------------------------------------------------------------------
805 1041
@@ -805,9 +1041,9 @@
805 1041
806 1042 /**
807 1043 * Create an agent: synthetic user row + definition meta.
808 1044 *
809 - * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array} $args Creation args.
1045 + * @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.
810 1046 * @return WP_User|WP_Error
811 1047 */
812 1048 function openstation_agent_create( $args ) {
813 1049 $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : '';
@@ -826,8 +1062,19 @@
826 1062
827 1063 $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : '';
828 1064 $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : '';
829 1065 $abilities = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array();
1066 + $triggers = isset( $args['triggers'] ) ? openstation_agent_sanitize_triggers( $args['triggers'] ) : array();
1067 + $vibes = isset( $args['vibes'] ) ? openstation_agent_sanitize_vibes( $args['vibes'] ) : '';
1068 + $face = isset( $args['face'] ) ? openstation_agent_sanitize_face_json( $args['face'] ) : '';
1069 + // Every agent gets a seed even when nobody chose a face, so the
1070 + // backfill has something deterministic to roll from and two admins
1071 + // racing it land on the same portrait. `crc32` of the login is
1072 + // stable, cheap, and already unique per agent.
1073 + $seed = isset( $args['faceSeed'] ) ? absint( $args['faceSeed'] ) : 0;
1074 + if ( 0 === $seed ) {
1075 + $seed = crc32( (string) $user->user_login );
1076 + }
830 1077
831 1078 if ( '' !== $description ) {
832 1079 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
833 1080 }
@@ -836,8 +1083,18 @@
836 1083 }
837 1084 if ( ! empty( $abilities ) ) {
838 1085 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
839 1086 }
1087 + if ( ! empty( $triggers ) ) {
1088 + update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
1089 + }
1090 + if ( '' !== $vibes ) {
1091 + update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1092 + }
1093 + if ( '' !== $face ) {
1094 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1095 + }
1096 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
840 1097 update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() );
841 1098
842 1099 /**
843 1100 * Fires after an agent is created.
@@ -855,8 +1112,11 @@
855 1112 'role' => $role,
856 1113 'description' => $description,
857 1114 'instructions' => $instructions,
858 1115 'abilities' => $abilities,
1116 + 'vibes' => $vibes,
1117 + 'face' => $face,
1118 + 'faceSeed' => $seed,
859 1119 ),
860 1120 get_current_user_id()
861 1121 );
862 1122
@@ -868,9 +1128,10 @@
868 1128 * fields, applies the valid ones, and fires `openstation_agent_updated`
869 1129 * once with a before/after map of everything that changed.
870 1130 *
871 1131 * Recognized fields: `name`, `role`, `description`, `instructions`,
872 - * `abilities`, `triggers`, `model`, `rateLimit`.
1132 + * `abilities`, `triggers`, `model`, `rateLimit`, `vibes`, `face`,
1133 + * `faceSeed`.
873 1134 *
874 1135 * @param int $user_id Agent user id.
875 1136 * @param array $fields Field map.
876 1137 * @return true|WP_Error
@@ -893,9 +1154,12 @@
893 1154 'openstation_agent_invalid_name',
894 1155 __( 'Agent name cannot be empty.', 'desktop-mode' )
895 1156 );
896 1157 }
897 - if ( $name !== (string) $user->display_name ) {
1158 + // Compared as plain text: the stored name carries entities
1159 + // (`&amp;`), and the client sends back the decoded one it was
1160 + // given on every save. Raw, that reads as a rename.
1161 + if ( openstation_plain_text_title( $name ) !== openstation_plain_text_title( $user->display_name ) ) {
898 1162 $changed['name'] = array(
899 1163 'from' => (string) $user->display_name,
900 1164 'to' => $name,
901 1165 );
@@ -1003,8 +1267,52 @@
1003 1267 delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META );
1004 1268 } else {
1005 1269 update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate );
1006 1270 }
1271 + }
1272 + }
1273 +
1274 + if ( isset( $fields['vibes'] ) ) {
1275 + $vibes = openstation_agent_sanitize_vibes( $fields['vibes'] );
1276 + $before = openstation_agent_get_vibes( $user->ID );
1277 + if ( $vibes !== $before ) {
1278 + $changed['vibes'] = array(
1279 + 'from' => $before,
1280 + 'to' => $vibes,
1281 + );
1282 + if ( '' === $vibes ) {
1283 + delete_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META );
1284 + } else {
1285 + update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1286 + }
1287 + }
1288 + }
1289 +
1290 + if ( isset( $fields['face'] ) ) {
1291 + $face = openstation_agent_sanitize_face_json( $fields['face'] );
1292 + $before = (string) get_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, true );
1293 + if ( $face !== $before ) {
1294 + $changed['face'] = array(
1295 + 'from' => $before,
1296 + 'to' => $face,
1297 + );
1298 + if ( '' === $face ) {
1299 + delete_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META );
1300 + } else {
1301 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1302 + }
1303 + }
1304 + }
1305 +
1306 + if ( isset( $fields['faceSeed'] ) ) {
1307 + $seed = absint( $fields['faceSeed'] );
1308 + $before = openstation_agent_get_face_seed( $user->ID );
1309 + if ( $seed !== $before ) {
1310 + $changed['faceSeed'] = array(
1311 + 'from' => $before,
1312 + 'to' => $seed,
1313 + );
1314 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
1007 1315 }
1008 1316 }
1009 1317
1010 1318 if ( ! empty( $changed ) ) {