| @@ -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 |
| @@ -805,9 +981,9 @@ | ||
| 805 | 981 | |
| 806 | 982 | /** |
| 807 | 983 | * Create an agent: synthetic user row + definition meta. |
| 808 | 984 | * |
| 809 | - * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array} $args Creation args. | |
| 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. | |
| 810 | 986 | * @return WP_User|WP_Error |
| 811 | 987 | */ |
| 812 | 988 | function openstation_agent_create( $args ) { |
| 813 | 989 | $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : ''; |
| @@ -826,8 +1002,19 @@ | ||
| 826 | 1002 | |
| 827 | 1003 | $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : ''; |
| 828 | 1004 | $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : ''; |
| 829 | 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 | + } | |
| 830 | 1017 | |
| 831 | 1018 | if ( '' !== $description ) { |
| 832 | 1019 | update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description ); |
| 833 | 1020 | } |
| @@ -836,8 +1023,18 @@ | ||
| 836 | 1023 | } |
| 837 | 1024 | if ( ! empty( $abilities ) ) { |
| 838 | 1025 | update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) ); |
| 839 | 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 ); | |
| 840 | 1037 | update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() ); |
| 841 | 1038 | |
| 842 | 1039 | /** |
| 843 | 1040 | * Fires after an agent is created. |
| @@ -855,8 +1052,11 @@ | ||
| 855 | 1052 | 'role' => $role, |
| 856 | 1053 | 'description' => $description, |
| 857 | 1054 | 'instructions' => $instructions, |
| 858 | 1055 | 'abilities' => $abilities, |
| 1056 | + 'vibes' => $vibes, | |
| 1057 | + 'face' => $face, | |
| 1058 | + 'faceSeed' => $seed, | |
| 859 | 1059 | ), |
| 860 | 1060 | get_current_user_id() |
| 861 | 1061 | ); |
| 862 | 1062 | |
| @@ -868,9 +1068,10 @@ | ||
| 868 | 1068 | * fields, applies the valid ones, and fires `openstation_agent_updated` |
| 869 | 1069 | * once with a before/after map of everything that changed. |
| 870 | 1070 | * |
| 871 | 1071 | * Recognized fields: `name`, `role`, `description`, `instructions`, |
| 872 | - * `abilities`, `triggers`, `model`, `rateLimit`. | |
| 1072 | + * `abilities`, `triggers`, `model`, `rateLimit`, `vibes`, `face`, | |
| 1073 | + * `faceSeed`. | |
| 873 | 1074 | * |
| 874 | 1075 | * @param int $user_id Agent user id. |
| 875 | 1076 | * @param array $fields Field map. |
| 876 | 1077 | * @return true|WP_Error |
| @@ -1003,8 +1204,52 @@ | ||
| 1003 | 1204 | delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META ); |
| 1004 | 1205 | } else { |
| 1005 | 1206 | update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate ); |
| 1006 | 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 ); | |
| 1007 | 1252 | } |
| 1008 | 1253 | } |
| 1009 | 1254 | |
| 1010 | 1255 | if ( ! empty( $changed ) ) { |