| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — the error-investigation skill. |
| 4 |
* |
| 5 |
* Four read-only WordPress Abilities that together give an assistant or |
| 6 |
* an agent enough to do what a developer does with a stack trace: find |
| 7 |
* out what is failing, read the code at the line that failed, learn |
| 8 |
* whose code it is and what version of everything it is running on — |
| 9 |
* and then say what it thinks the fix is. |
| 10 |
* |
| 11 |
* list_log_issues what is failing, grouped and counted |
| 12 |
* get_log_issue one issue in full, with its stack trace |
| 13 |
* read_source_excerpt the code around a line the log named |
| 14 |
* get_site_context versions, debug flags, active plugins + theme |
| 15 |
* |
| 16 |
* **The skill proposes; it never repairs.** That is structural, not a |
| 17 |
* matter of prompting: every ability here is `readonly`, and no writing |
| 18 |
* counterpart exists, so a model handed this whole set can read the |
| 19 |
* evidence and describe a patch and has no route to apply one. The |
| 20 |
* prompt appendix below says the same thing in words, because a model |
| 21 |
* that does not know it cannot edit files tends to answer as though it |
| 22 |
* already had. |
| 23 |
* |
| 24 |
* All four read through Code Blue's own model (`log-reader.php`), so |
| 25 |
* the assistant and the window can never disagree about what the log |
| 26 |
* says or whose plugin a file belongs to. |
| 27 |
* |
| 28 |
* Read `docs/agents-security.md` before adding to this file. The |
| 29 |
* dangerous one is `read_source_excerpt` — see the guards on |
| 30 |
* {@see openstation_ai_debug_resolve_source_path()}. |
| 31 |
* |
| 32 |
* @package OpenStation |
| 33 |
*/ |
| 34 |
|
| 35 |
defined( 'ABSPATH' ) || exit; |
| 36 |
|
| 37 |
use function OpenStation\Apps\CodeBlue\can_use; |
| 38 |
use function OpenStation\Apps\CodeBlue\read as read_log; |
| 39 |
use function OpenStation\Apps\CodeBlue\sources as log_sources; |
| 40 |
use function OpenStation\Apps\CodeBlue\usable as log_usable; |
| 41 |
|
| 42 |
/** Longest excerpt `read_source_excerpt` will return, in lines. */ |
| 43 |
const OPENSTATION_AI_DEBUG_MAX_EXCERPT = 200; |
| 44 |
|
| 45 |
/** |
| 46 |
* Who may use the debugging skill: whoever may open Code Blue. |
| 47 |
* |
| 48 |
* Deliberately the same gate rather than a new one. These abilities are |
| 49 |
* the window's contents addressed by an assistant instead of a pointer, |
| 50 |
* and a site where the log window is off is a site that has said it |
| 51 |
* does not want its error log read — through the UI or otherwise. |
| 52 |
* `openstation_code_blue_user_can_use` moves both at once. |
| 53 |
* |
| 54 |
* @return bool |
| 55 |
*/ |
| 56 |
function openstation_ai_debug_can_use() { |
| 57 |
if ( ! function_exists( 'openstation_apps_os' ) ) { |
| 58 |
return false; |
| 59 |
} |
| 60 |
return can_use( openstation_apps_os() ); |
| 61 |
} |
| 62 |
|
| 63 |
/** |
| 64 |
* Every parsed entry from a log source, newest first. |
| 65 |
* |
| 66 |
* @param string $source_id Source id, or '' for the first usable one. |
| 67 |
* @return array{source:array<string,mixed>|null,entries:array[],error:string} |
| 68 |
*/ |
| 69 |
function openstation_ai_debug_entries( $source_id = '' ) { |
| 70 |
$os = openstation_apps_os(); |
| 71 |
$sources = log_sources( $os ); |
| 72 |
$source = null; |
| 73 |
foreach ( $sources as $candidate ) { |
| 74 |
if ( ! log_usable( $candidate ) ) { |
| 75 |
continue; |
| 76 |
} |
| 77 |
if ( '' === $source_id || $candidate['id'] === $source_id ) { |
| 78 |
$source = $candidate; |
| 79 |
break; |
| 80 |
} |
| 81 |
} |
| 82 |
if ( null === $source ) { |
| 83 |
return array( |
| 84 |
'source' => null, |
| 85 |
'entries' => array(), |
| 86 |
'error' => '' === $source_id |
| 87 |
? __( 'No readable log file was found on this install.', 'desktop-mode' ) |
| 88 |
: __( 'No readable log file matches that source id.', 'desktop-mode' ), |
| 89 |
); |
| 90 |
} |
| 91 |
|
| 92 |
$read = read_log( $os, $source ); |
| 93 |
return array( |
| 94 |
'source' => $source, |
| 95 |
'entries' => array_reverse( $read['entries'] ), |
| 96 |
'error' => (string) $read['error'], |
| 97 |
); |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Fold entries into issues by signature — same key the window groups |
| 102 |
* on, computed once in `log-reader.php`, so an issue the assistant |
| 103 |
* names is the issue the user is looking at. |
| 104 |
* |
| 105 |
* This is not the window's `groupEntries()`: that one folds what |
| 106 |
* survived the user's range and search filters, in the browser, and |
| 107 |
* exists so those filters cost nothing. This one folds the whole read. |
| 108 |
* |
| 109 |
* @param array[] $entries Parsed entries. |
| 110 |
* @param int $since Unix seconds floor, or 0 for no floor. |
| 111 |
* @return array[] Issues, most recent first. |
| 112 |
*/ |
| 113 |
function openstation_ai_debug_group( array $entries, $since = 0 ) { |
| 114 |
$issues = array(); |
| 115 |
foreach ( $entries as $entry ) { |
| 116 |
$stamp = isset( $entry['timestamp'] ) ? $entry['timestamp'] : null; |
| 117 |
if ( $since > 0 && ( null === $stamp || $stamp < $since ) ) { |
| 118 |
continue; |
| 119 |
} |
| 120 |
$key = (string) $entry['signature']; |
| 121 |
if ( ! isset( $issues[ $key ] ) ) { |
| 122 |
$issues[ $key ] = array( |
| 123 |
'signature' => $key, |
| 124 |
'level' => (string) $entry['level'], |
| 125 |
'label' => (string) $entry['label'], |
| 126 |
'message' => (string) $entry['message'], |
| 127 |
'file' => (string) $entry['file'], |
| 128 |
'line' => (int) $entry['line'], |
| 129 |
'origin' => isset( $entry['origin'] ) ? $entry['origin'] : array( |
| 130 |
'kind' => 'unknown', |
| 131 |
'slug' => '', |
| 132 |
), |
| 133 |
'count' => 0, |
| 134 |
'first_seen' => $stamp, |
| 135 |
'last_seen' => $stamp, |
| 136 |
'trace' => (string) $entry['trace'], |
| 137 |
); |
| 138 |
} |
| 139 |
$issue = &$issues[ $key ]; |
| 140 |
++$issue['count']; |
| 141 |
// The longest trace wins: a fatal is logged repeatedly and only |
| 142 |
// some occurrences carry the full frame list. |
| 143 |
if ( strlen( (string) $entry['trace'] ) > strlen( $issue['trace'] ) ) { |
| 144 |
$issue['trace'] = (string) $entry['trace']; |
| 145 |
} |
| 146 |
if ( null !== $stamp ) { |
| 147 |
$issue['first_seen'] = null === $issue['first_seen'] ? $stamp : min( $issue['first_seen'], $stamp ); |
| 148 |
$issue['last_seen'] = null === $issue['last_seen'] ? $stamp : max( $issue['last_seen'], $stamp ); |
| 149 |
} |
| 150 |
unset( $issue ); |
| 151 |
} |
| 152 |
return array_values( $issues ); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Attach the human name behind an origin slug — "woocommerce" is what |
| 157 |
* the path says, "WooCommerce 9.4.2" is what the developer knows it |
| 158 |
* as, and the version is half of every compatibility answer. |
| 159 |
* |
| 160 |
* Lives here rather than in the app because resolving it needs |
| 161 |
* WordPress; `log-reader.php` runs on hosts that have no `get_plugins()`. |
| 162 |
* |
| 163 |
* @param array<string,string> $origin `kind` + `slug` from the log model. |
| 164 |
* @return array<string,string> The same, plus `name` and `version` when known. |
| 165 |
*/ |
| 166 |
function openstation_ai_debug_name_origin( array $origin ) { |
| 167 |
$origin += array( |
| 168 |
'kind' => 'unknown', |
| 169 |
'slug' => '', |
| 170 |
); |
| 171 |
$origin['name'] = ''; |
| 172 |
$origin['version'] = ''; |
| 173 |
|
| 174 |
if ( 'core' === $origin['kind'] ) { |
| 175 |
$origin['name'] = 'WordPress'; |
| 176 |
$origin['version'] = (string) get_bloginfo( 'version' ); |
| 177 |
return $origin; |
| 178 |
} |
| 179 |
if ( '' === $origin['slug'] ) { |
| 180 |
return $origin; |
| 181 |
} |
| 182 |
|
| 183 |
if ( 'theme' === $origin['kind'] ) { |
| 184 |
$theme = wp_get_theme( $origin['slug'] ); |
| 185 |
if ( $theme->exists() ) { |
| 186 |
$origin['name'] = (string) $theme->get( 'Name' ); |
| 187 |
$origin['version'] = (string) $theme->get( 'Version' ); |
| 188 |
} |
| 189 |
return $origin; |
| 190 |
} |
| 191 |
|
| 192 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 193 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 194 |
} |
| 195 |
$plugins = 'mu-plugin' === $origin['kind'] ? get_mu_plugins() : get_plugins(); |
| 196 |
foreach ( $plugins as $file => $data ) { |
| 197 |
$slug = false === strpos( $file, '/' ) ? preg_replace( '/\.php$/', '', $file ) : dirname( $file ); |
| 198 |
if ( $slug === $origin['slug'] ) { |
| 199 |
$origin['name'] = (string) $data['Name']; |
| 200 |
$origin['version'] = (string) $data['Version']; |
| 201 |
break; |
| 202 |
} |
| 203 |
} |
| 204 |
return $origin; |
| 205 |
} |
| 206 |
|
| 207 |
/** |
| 208 |
* Every file path the current log names — in an entry's `file` field or |
| 209 |
* anywhere inside a stack trace. |
| 210 |
* |
| 211 |
* This set is the allowlist `read_source_excerpt` resolves against, and |
| 212 |
* it is the guard that matters. Bounding source reads to "files this |
| 213 |
* install has already written into its own error log" means the |
| 214 |
* ability can never widen what the caller can see: a path only enters |
| 215 |
* the set because something already failed there, and the log itself |
| 216 |
* is readable to exactly the same people. An ability that took any |
| 217 |
* path under ABSPATH would instead be a general file-read tool wearing |
| 218 |
* a debugging label — reachable, through the assistant, by whatever |
| 219 |
* text the model happens to be reading. |
| 220 |
* |
| 221 |
* @param array[] $entries Parsed entries. |
| 222 |
* @return array<string,true> Paths as keys. |
| 223 |
*/ |
| 224 |
function openstation_ai_debug_known_paths( array $entries ) { |
| 225 |
$paths = array(); |
| 226 |
foreach ( $entries as $entry ) { |
| 227 |
if ( ! empty( $entry['file'] ) ) { |
| 228 |
$paths[ str_replace( '\\', '/', (string) $entry['file'] ) ] = true; |
| 229 |
} |
| 230 |
if ( empty( $entry['trace'] ) ) { |
| 231 |
continue; |
| 232 |
} |
| 233 |
// Stack-trace frames: `#0 /abs/path/file.php(123): fn()`, and |
| 234 |
// the `thrown in /abs/path` tail. |
| 235 |
if ( preg_match_all( '#(/[^\s:()\'"]+\.(?:php|inc))#', (string) $entry['trace'], $found ) ) { |
| 236 |
foreach ( $found[1] as $path ) { |
| 237 |
$paths[ $path ] = true; |
| 238 |
} |
| 239 |
} |
| 240 |
} |
| 241 |
return $paths; |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Resolve a requested source path, or explain why not. |
| 246 |
* |
| 247 |
* Four gates, in order of what each one closes: |
| 248 |
* |
| 249 |
* 1. The path must be one the current log names — see |
| 250 |
* {@see openstation_ai_debug_known_paths()}. This is the real |
| 251 |
* boundary; the rest are belt and braces for the day someone |
| 252 |
* relaxes it. |
| 253 |
* 2. It must resolve (symlinks included) inside the WordPress root |
| 254 |
* or the content directory. `realpath()` before the prefix test, |
| 255 |
* so `../` cannot walk out. |
| 256 |
* 3. It must be a source file by extension. A log can name a `.log` |
| 257 |
* or a `.sql`; those are data, and data is where secrets live. |
| 258 |
* 4. Configuration is refused outright even when the log names it — |
| 259 |
* and a fatal inside `wp-config.php` does name it. That file is |
| 260 |
* the database password, the salts and the keys; nobody debugging |
| 261 |
* a stack trace needs it echoed back through a language model. |
| 262 |
* |
| 263 |
* @param string $file Requested absolute path. |
| 264 |
* @param array<string,true> $allowed Paths the log names. |
| 265 |
* @return string|WP_Error Real path, or the reason it was refused. |
| 266 |
*/ |
| 267 |
function openstation_ai_debug_resolve_source_path( $file, array $allowed ) { |
| 268 |
$requested = str_replace( '\\', '/', (string) $file ); |
| 269 |
if ( ! isset( $allowed[ $requested ] ) ) { |
| 270 |
return new WP_Error( |
| 271 |
'openstation_ai_debug_unknown_file', |
| 272 |
__( 'That file is not named anywhere in the current log. Only files an entry or a stack trace mentions can be read.', 'desktop-mode' ) |
| 273 |
); |
| 274 |
} |
| 275 |
|
| 276 |
$real = realpath( $requested ); |
| 277 |
if ( false === $real || ! is_file( $real ) || ! is_readable( $real ) ) { |
| 278 |
return new WP_Error( 'openstation_ai_debug_unreadable', __( 'That file does not exist on disk or cannot be read.', 'desktop-mode' ) ); |
| 279 |
} |
| 280 |
$real = str_replace( '\\', '/', $real ); |
| 281 |
|
| 282 |
$roots = array( realpath( ABSPATH ), realpath( WP_CONTENT_DIR ) ); |
| 283 |
$in = false; |
| 284 |
foreach ( $roots as $root ) { |
| 285 |
if ( false !== $root && 0 === strpos( $real, rtrim( str_replace( '\\', '/', $root ), '/' ) . '/' ) ) { |
| 286 |
$in = true; |
| 287 |
break; |
| 288 |
} |
| 289 |
} |
| 290 |
if ( ! $in ) { |
| 291 |
return new WP_Error( 'openstation_ai_debug_outside_root', __( 'That file lives outside the WordPress installation.', 'desktop-mode' ) ); |
| 292 |
} |
| 293 |
|
| 294 |
if ( ! preg_match( '/\.(php|inc|js|jsx|ts|tsx|css|scss)$/i', $real ) ) { |
| 295 |
return new WP_Error( 'openstation_ai_debug_not_source', __( 'Only source files can be read — not logs, dumps, or data files.', 'desktop-mode' ) ); |
| 296 |
} |
| 297 |
|
| 298 |
$base = strtolower( basename( $real ) ); |
| 299 |
if ( in_array( $base, array( 'wp-config.php', 'wp-config-sample.php' ), true ) || preg_match( '/^\.env/', $base ) ) { |
| 300 |
return new WP_Error( |
| 301 |
'openstation_ai_debug_secret_file', |
| 302 |
__( 'Configuration files are never readable through this tool — they hold database credentials and salts. Describe what you need from it instead.', 'desktop-mode' ) |
| 303 |
); |
| 304 |
} |
| 305 |
|
| 306 |
return $real; |
| 307 |
} |
| 308 |
|
| 309 |
/** |
| 310 |
* Read `$context` lines either side of `$line`. |
| 311 |
* |
| 312 |
* @param string $path Resolved real path. |
| 313 |
* @param int $line Centre line, 1-based; 0 reads from the top. |
| 314 |
* @param int $context Lines either side. |
| 315 |
* @return array<string,mixed> |
| 316 |
*/ |
| 317 |
function openstation_ai_debug_excerpt( $path, $line, $context ) { |
| 318 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file -- Reading a source file already resolved and allowlisted above; WP_Filesystem adds nothing here and is not available this early on every host. |
| 319 |
$all = file( $path, FILE_IGNORE_NEW_LINES ); |
| 320 |
if ( false === $all ) { |
| 321 |
return array( |
| 322 |
'file' => $path, |
| 323 |
'lines' => array(), |
| 324 |
'error' => __( 'The file could not be read.', 'desktop-mode' ), |
| 325 |
); |
| 326 |
} |
| 327 |
|
| 328 |
$total = count( $all ); |
| 329 |
$line = max( 0, min( (int) $line, $total ) ); |
| 330 |
$start = $line > 0 ? max( 1, $line - $context ) : 1; |
| 331 |
$end = $line > 0 ? min( $total, $line + $context ) : min( $total, OPENSTATION_AI_DEBUG_MAX_EXCERPT ); |
| 332 |
|
| 333 |
$lines = array(); |
| 334 |
for ( $n = $start; $n <= $end; $n++ ) { |
| 335 |
$lines[] = array( |
| 336 |
'number' => $n, |
| 337 |
'text' => $all[ $n - 1 ], |
| 338 |
); |
| 339 |
} |
| 340 |
|
| 341 |
return array( |
| 342 |
'file' => $path, |
| 343 |
'total_lines' => $total, |
| 344 |
'start_line' => $start, |
| 345 |
'end_line' => $end, |
| 346 |
'lines' => $lines, |
| 347 |
'error' => '', |
| 348 |
); |
| 349 |
} |
| 350 |
|
| 351 |
/** |
| 352 |
* Registers the debugging abilities. |
| 353 |
* |
| 354 |
* @return void |
| 355 |
*/ |
| 356 |
function openstation_ai_register_debug_abilities() { |
| 357 |
if ( ! function_exists( 'wp_register_ability' ) ) { |
| 358 |
return; |
| 359 |
} |
| 360 |
|
| 361 |
// Never `mcp.public`: the log and the source behind it are this |
| 362 |
// site's internals, and an external agent has no business in them |
| 363 |
// however read-only the tools are. |
| 364 |
$meta = array( |
| 365 |
'annotations' => array( |
| 366 |
'readonly' => true, |
| 367 |
'idempotent' => true, |
| 368 |
), |
| 369 |
'show_in_rest' => true, |
| 370 |
); |
| 371 |
|
| 372 |
wp_register_ability( |
| 373 |
'desktop-mode/list-log-issues', |
| 374 |
array( |
| 375 |
'label' => __( 'List error-log issues', 'desktop-mode' ), |
| 376 |
'description' => 'Lists what is currently failing on this site: the PHP error log parsed into DISTINCT issues rather than raw lines, so a fatal that fired 400 times is one entry with count 400. Start every debugging investigation here. Each issue carries { signature, level, label, message, file, line, count, first_seen, last_seen, origin } where `origin` says whose code it is ({ kind: plugin|mu-plugin|theme|core|unknown, slug, name, version }) and `signature` is the id you pass to get_log_issue and quote back to the user. Stack traces are NOT included — call get_log_issue for the one you are working on. Administrators with Developer mode on only.', |
| 377 |
'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 378 |
'input_schema' => array( |
| 379 |
'type' => 'object', |
| 380 |
'additionalProperties' => false, |
| 381 |
'required' => array( 'limit' ), |
| 382 |
'properties' => array( |
| 383 |
'limit' => array( |
| 384 |
'type' => 'integer', |
| 385 |
'description' => 'How many issues to return, most recently seen first (1-50). Use 10 unless the user asked for a survey.', |
| 386 |
), |
| 387 |
'range' => array( |
| 388 |
'type' => 'string', |
| 389 |
'enum' => array( '1h', '24h', '7d', '30d', 'all' ), |
| 390 |
'description' => 'How far back to look. Defaults to `all`. Narrow it when the user says "since I updated" or "today".', |
| 391 |
), |
| 392 |
'source' => array( |
| 393 |
'type' => 'string', |
| 394 |
'description' => 'Log source id. Omit for the first readable one, which is what the user sees by default.', |
| 395 |
), |
| 396 |
'level' => array( |
| 397 |
'type' => 'string', |
| 398 |
'enum' => array( 'fatal', 'error', 'warning', 'deprecated', 'notice', 'info' ), |
| 399 |
'description' => 'Only issues at this severity. Omit for all. `fatal` is what breaks a site; `deprecated` is usually noise.', |
| 400 |
), |
| 401 |
), |
| 402 |
), |
| 403 |
'output_schema' => openstation_ai_ability_output_schema( |
| 404 |
array( |
| 405 |
'issues' => array( |
| 406 |
'type' => 'array', |
| 407 |
'description' => 'Grouped issues, most recently seen first.', |
| 408 |
), |
| 409 |
'count' => array( 'type' => 'integer' ), |
| 410 |
'source' => array( |
| 411 |
'type' => 'object', |
| 412 |
'description' => 'The log that was read: { id, label, path, size }.', |
| 413 |
), |
| 414 |
'message' => array( |
| 415 |
'type' => 'string', |
| 416 |
'description' => 'Set when no log could be read — explain it to the user rather than guessing.', |
| 417 |
), |
| 418 |
) |
| 419 |
), |
| 420 |
'execute_callback' => 'openstation_ai_debug_list_issues', |
| 421 |
'permission_callback' => 'openstation_ai_debug_can_use', |
| 422 |
'meta' => $meta, |
| 423 |
) |
| 424 |
); |
| 425 |
|
| 426 |
wp_register_ability( |
| 427 |
'desktop-mode/get-log-issue', |
| 428 |
array( |
| 429 |
'label' => __( 'Get one error-log issue', 'desktop-mode' ), |
| 430 |
'description' => 'Returns ONE issue from the error log in full, including its stack trace, by the `signature` you got from list_log_issues. Call this on the issue you are actually investigating — the trace names the file and line where the failure started and the chain of calls that reached it, which is what you read the source at. The trace is verbatim server output: paths in it are real paths you may pass to read_source_excerpt.', |
| 431 |
'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 432 |
'input_schema' => array( |
| 433 |
'type' => 'object', |
| 434 |
'additionalProperties' => false, |
| 435 |
'required' => array( 'signature' ), |
| 436 |
'properties' => array( |
| 437 |
'signature' => array( |
| 438 |
'type' => 'string', |
| 439 |
'description' => 'The issue id from a prior list_log_issues call. Pass it exactly as given.', |
| 440 |
), |
| 441 |
'source' => array( |
| 442 |
'type' => 'string', |
| 443 |
'description' => 'Log source id, if you passed one to list_log_issues.', |
| 444 |
), |
| 445 |
), |
| 446 |
), |
| 447 |
'output_schema' => openstation_ai_ability_output_schema( |
| 448 |
array( |
| 449 |
'issue' => array( |
| 450 |
'type' => 'object', |
| 451 |
'description' => 'The issue with its `trace`, or absent when the signature is unknown.', |
| 452 |
), |
| 453 |
'message' => array( 'type' => 'string' ), |
| 454 |
) |
| 455 |
), |
| 456 |
'execute_callback' => 'openstation_ai_debug_get_issue', |
| 457 |
'permission_callback' => 'openstation_ai_debug_can_use', |
| 458 |
'meta' => $meta, |
| 459 |
) |
| 460 |
); |
| 461 |
|
| 462 |
wp_register_ability( |
| 463 |
'desktop-mode/read-source-excerpt', |
| 464 |
array( |
| 465 |
'label' => __( 'Read source around a logged line', 'desktop-mode' ), |
| 466 |
'description' => 'Reads the PHP (or JS/CSS) source around a line the error log named, so you can see the code that failed instead of guessing at it. Pass a `file` path and `line` taken from an issue or from its stack trace. Returns numbered lines so you can quote them precisely. IMPORTANT LIMITS, and they are refusals rather than empty results: only files the CURRENT log actually mentions can be read, only inside this WordPress install, only source extensions, and never wp-config.php or a .env — if you need a value from configuration, ask the user for it. You are reading this file to EXPLAIN and to PROPOSE a change; you have no ability to write it, so give the user the edit to make.', |
| 467 |
'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 468 |
'input_schema' => array( |
| 469 |
'type' => 'object', |
| 470 |
'additionalProperties' => false, |
| 471 |
'required' => array( 'file', 'line' ), |
| 472 |
'properties' => array( |
| 473 |
'file' => array( |
| 474 |
'type' => 'string', |
| 475 |
'description' => 'Absolute path exactly as the log wrote it — from an issue\'s `file` or a path inside its stack trace.', |
| 476 |
), |
| 477 |
'line' => array( |
| 478 |
'type' => 'integer', |
| 479 |
'description' => 'The line to centre on. Use 0 to read from the top of the file (a class or function you need the shape of).', |
| 480 |
), |
| 481 |
'context' => array( |
| 482 |
'type' => 'integer', |
| 483 |
'description' => 'Lines either side of `line` (1-100). Defaults to 25 — enough for the enclosing function. Widen once if the cause is clearly further up.', |
| 484 |
), |
| 485 |
), |
| 486 |
), |
| 487 |
'output_schema' => openstation_ai_ability_output_schema( |
| 488 |
array( |
| 489 |
'file' => array( 'type' => 'string' ), |
| 490 |
'total_lines' => array( 'type' => 'integer' ), |
| 491 |
'start_line' => array( 'type' => 'integer' ), |
| 492 |
'end_line' => array( 'type' => 'integer' ), |
| 493 |
'lines' => array( |
| 494 |
'type' => 'array', |
| 495 |
'description' => 'Numbered source lines: { number, text }.', |
| 496 |
), |
| 497 |
) |
| 498 |
), |
| 499 |
'execute_callback' => 'openstation_ai_debug_read_source', |
| 500 |
'permission_callback' => 'openstation_ai_debug_can_use', |
| 501 |
'meta' => $meta, |
| 502 |
) |
| 503 |
); |
| 504 |
|
| 505 |
wp_register_ability( |
| 506 |
'desktop-mode/get-site-context', |
| 507 |
array( |
| 508 |
'label' => __( 'Get site debugging context', 'desktop-mode' ), |
| 509 |
'description' => 'Returns what this site is actually running: WordPress and PHP versions, the debug constants, the environment type, the active theme, and every active plugin with its version. Call this before proposing a fix — most WordPress errors are a version story ("that function was removed in PHP 8.1", "that plugin has not been updated since 2021"), and the answer is not in the log. Contains no credentials.', |
| 510 |
'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 511 |
'input_schema' => array( |
| 512 |
'type' => 'object', |
| 513 |
'additionalProperties' => false, |
| 514 |
'required' => array(), |
| 515 |
'properties' => (object) array(), |
| 516 |
), |
| 517 |
'output_schema' => openstation_ai_ability_output_schema( |
| 518 |
array( |
| 519 |
'wordpress' => array( 'type' => 'object' ), |
| 520 |
'php' => array( 'type' => 'object' ), |
| 521 |
'debug' => array( 'type' => 'object' ), |
| 522 |
'theme' => array( 'type' => 'object' ), |
| 523 |
'plugins' => array( |
| 524 |
'type' => 'array', |
| 525 |
'description' => 'Active plugins: { name, slug, version }.', |
| 526 |
), |
| 527 |
'mu_plugins' => array( 'type' => 'array' ), |
| 528 |
) |
| 529 |
), |
| 530 |
'execute_callback' => 'openstation_ai_debug_site_context', |
| 531 |
'permission_callback' => 'openstation_ai_debug_can_use', |
| 532 |
'meta' => $meta, |
| 533 |
) |
| 534 |
); |
| 535 |
} |
| 536 |
add_action( 'wp_abilities_api_init', 'openstation_ai_register_debug_abilities', 11 ); |
| 537 |
|
| 538 |
/** |
| 539 |
* `list_log_issues` — grouped issues from a log source. |
| 540 |
* |
| 541 |
* @param array<string,mixed> $input Validated input. |
| 542 |
* @return array<string,mixed> |
| 543 |
*/ |
| 544 |
function openstation_ai_debug_list_issues( $input ) { |
| 545 |
$input += array( |
| 546 |
'limit' => 10, |
| 547 |
'range' => 'all', |
| 548 |
'source' => '', |
| 549 |
'level' => '', |
| 550 |
); |
| 551 |
|
| 552 |
$read = openstation_ai_debug_entries( (string) $input['source'] ); |
| 553 |
if ( null === $read['source'] ) { |
| 554 |
return array( |
| 555 |
'issues' => array(), |
| 556 |
'count' => 0, |
| 557 |
'message' => $read['error'], |
| 558 |
); |
| 559 |
} |
| 560 |
|
| 561 |
$spans = array( |
| 562 |
'1h' => HOUR_IN_SECONDS, |
| 563 |
'24h' => DAY_IN_SECONDS, |
| 564 |
'7d' => WEEK_IN_SECONDS, |
| 565 |
'30d' => MONTH_IN_SECONDS, |
| 566 |
'all' => 0, |
| 567 |
); |
| 568 |
$span = isset( $spans[ $input['range'] ] ) ? $spans[ $input['range'] ] : 0; |
| 569 |
$issues = openstation_ai_debug_group( $read['entries'], $span > 0 ? time() - $span : 0 ); |
| 570 |
|
| 571 |
$level = (string) $input['level']; |
| 572 |
if ( '' !== $level ) { |
| 573 |
$issues = array_values( |
| 574 |
array_filter( |
| 575 |
$issues, |
| 576 |
static function ( $issue ) use ( $level ) { |
| 577 |
return $issue['level'] === $level; |
| 578 |
} |
| 579 |
) |
| 580 |
); |
| 581 |
} |
| 582 |
|
| 583 |
$limit = max( 1, min( 50, (int) $input['limit'] ) ); |
| 584 |
$issues = array_slice( $issues, 0, $limit ); |
| 585 |
foreach ( $issues as $index => $issue ) { |
| 586 |
// The list is a triage view: the trace is the expensive half and |
| 587 |
// only the issue being worked on needs it. |
| 588 |
unset( $issues[ $index ]['trace'] ); |
| 589 |
$issues[ $index ]['origin'] = openstation_ai_debug_name_origin( (array) $issue['origin'] ); |
| 590 |
} |
| 591 |
|
| 592 |
return array( |
| 593 |
'issues' => $issues, |
| 594 |
'count' => count( $issues ), |
| 595 |
'source' => array( |
| 596 |
'id' => $read['source']['id'], |
| 597 |
'label' => $read['source']['label'], |
| 598 |
'path' => $read['source']['path'], |
| 599 |
'size' => $read['source']['size'], |
| 600 |
), |
| 601 |
'message' => $read['error'], |
| 602 |
); |
| 603 |
} |
| 604 |
|
| 605 |
/** |
| 606 |
* `get_log_issue` — one issue, trace included. |
| 607 |
* |
| 608 |
* @param array<string,mixed> $input Validated input. |
| 609 |
* @return array<string,mixed> |
| 610 |
*/ |
| 611 |
function openstation_ai_debug_get_issue( $input ) { |
| 612 |
$signature = isset( $input['signature'] ) ? (string) $input['signature'] : ''; |
| 613 |
$read = openstation_ai_debug_entries( isset( $input['source'] ) ? (string) $input['source'] : '' ); |
| 614 |
if ( null === $read['source'] ) { |
| 615 |
return array( 'message' => $read['error'] ); |
| 616 |
} |
| 617 |
|
| 618 |
foreach ( openstation_ai_debug_group( $read['entries'] ) as $issue ) { |
| 619 |
if ( $issue['signature'] === $signature ) { |
| 620 |
$issue['origin'] = openstation_ai_debug_name_origin( (array) $issue['origin'] ); |
| 621 |
return array( |
| 622 |
'issue' => $issue, |
| 623 |
'message' => '', |
| 624 |
); |
| 625 |
} |
| 626 |
} |
| 627 |
|
| 628 |
return array( |
| 629 |
'message' => __( 'No issue in the current log has that signature — it may have been cleared or aged out. Call list_log_issues again.', 'desktop-mode' ), |
| 630 |
); |
| 631 |
} |
| 632 |
|
| 633 |
/** |
| 634 |
* `read_source_excerpt` — code around a logged line. |
| 635 |
* |
| 636 |
* @param array<string,mixed> $input Validated input. |
| 637 |
* @return array<string,mixed>|WP_Error |
| 638 |
*/ |
| 639 |
function openstation_ai_debug_read_source( $input ) { |
| 640 |
$read = openstation_ai_debug_entries(); |
| 641 |
if ( null === $read['source'] ) { |
| 642 |
return new WP_Error( 'openstation_ai_debug_no_log', $read['error'] ); |
| 643 |
} |
| 644 |
|
| 645 |
$path = openstation_ai_debug_resolve_source_path( |
| 646 |
isset( $input['file'] ) ? (string) $input['file'] : '', |
| 647 |
openstation_ai_debug_known_paths( $read['entries'] ) |
| 648 |
); |
| 649 |
if ( is_wp_error( $path ) ) { |
| 650 |
return $path; |
| 651 |
} |
| 652 |
|
| 653 |
$context = isset( $input['context'] ) ? (int) $input['context'] : 25; |
| 654 |
return openstation_ai_debug_excerpt( $path, isset( $input['line'] ) ? (int) $input['line'] : 0, max( 1, min( 100, $context ) ) ); |
| 655 |
} |
| 656 |
|
| 657 |
/** |
| 658 |
* `get_site_context` — versions, flags, active code. |
| 659 |
* |
| 660 |
* @return array<string,mixed> |
| 661 |
*/ |
| 662 |
function openstation_ai_debug_site_context() { |
| 663 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 664 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 665 |
} |
| 666 |
|
| 667 |
$active = array(); |
| 668 |
$plugins = get_plugins(); |
| 669 |
foreach ( $plugins as $file => $data ) { |
| 670 |
if ( ! is_plugin_active( $file ) && ! ( is_multisite() && is_plugin_active_for_network( $file ) ) ) { |
| 671 |
continue; |
| 672 |
} |
| 673 |
$active[] = array( |
| 674 |
'name' => (string) $data['Name'], |
| 675 |
'slug' => false === strpos( $file, '/' ) ? preg_replace( '/\.php$/', '', $file ) : dirname( $file ), |
| 676 |
'version' => (string) $data['Version'], |
| 677 |
); |
| 678 |
} |
| 679 |
|
| 680 |
$mu = array(); |
| 681 |
foreach ( get_mu_plugins() as $file => $data ) { |
| 682 |
$mu[] = array( |
| 683 |
'name' => (string) $data['Name'], |
| 684 |
'slug' => preg_replace( '/\.php$/', '', $file ), |
| 685 |
'version' => (string) $data['Version'], |
| 686 |
); |
| 687 |
} |
| 688 |
|
| 689 |
$theme = wp_get_theme(); |
| 690 |
$debug = array(); |
| 691 |
foreach ( array( 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES', 'WP_DISABLE_FATAL_ERROR_HANDLER' ) as $constant ) { |
| 692 |
$debug[ $constant ] = defined( $constant ) ? (bool) constant( $constant ) : false; |
| 693 |
} |
| 694 |
|
| 695 |
return array( |
| 696 |
'wordpress' => array( |
| 697 |
'version' => (string) get_bloginfo( 'version' ), |
| 698 |
'multisite' => is_multisite(), |
| 699 |
'environment_type' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production', |
| 700 |
'locale' => (string) get_locale(), |
| 701 |
), |
| 702 |
'php' => array( |
| 703 |
'version' => PHP_VERSION, |
| 704 |
'memory_limit' => (string) ini_get( 'memory_limit' ), |
| 705 |
'max_execution' => (string) ini_get( 'max_execution_time' ), |
| 706 |
'extensions' => array_values( array_intersect( get_loaded_extensions(), array( 'curl', 'gd', 'imagick', 'intl', 'mbstring', 'mysqli', 'openssl', 'zip' ) ) ), |
| 707 |
), |
| 708 |
'debug' => $debug, |
| 709 |
'theme' => array( |
| 710 |
'name' => (string) $theme->get( 'Name' ), |
| 711 |
'slug' => (string) $theme->get_stylesheet(), |
| 712 |
'version' => (string) $theme->get( 'Version' ), |
| 713 |
'template' => (string) $theme->get_template(), |
| 714 |
), |
| 715 |
'plugins' => $active, |
| 716 |
'mu_plugins' => $mu, |
| 717 |
); |
| 718 |
} |
| 719 |
|
| 720 |
/** |
| 721 |
* The behavioural half of the skill. |
| 722 |
* |
| 723 |
* The abilities make investigation possible; this makes it a method, |
| 724 |
* and it states the one rule the tools cannot state for themselves. A |
| 725 |
* model that does not know it is unable to edit files will happily |
| 726 |
* answer "I've fixed that for you" — the ceiling is real either way, |
| 727 |
* but the sentence is a lie the user then has to discover. |
| 728 |
* |
| 729 |
* Only added for callers who can actually use the tools: a subscriber |
| 730 |
* asking about a post has no use for a debugging protocol, and every |
| 731 |
* unused line in a system prompt is paid for on every turn. |
| 732 |
* |
| 733 |
* @param string $appendix Appendix so far. |
| 734 |
* @param array<string,mixed> $ctx Request context. |
| 735 |
* @return string |
| 736 |
*/ |
| 737 |
function openstation_ai_debug_prompt_appendix( $appendix, $ctx = array() ) { |
| 738 |
unset( $ctx ); |
| 739 |
if ( ! openstation_ai_debug_can_use() ) { |
| 740 |
return $appendix; |
| 741 |
} |
| 742 |
|
| 743 |
$skill = <<<'PROMPT' |
| 744 |
## Investigating an error |
| 745 |
|
| 746 |
When the user reports something broken, asks about errors, or shows you |
| 747 |
a log message, work the evidence rather than guessing from the message |
| 748 |
alone: |
| 749 |
|
| 750 |
1. `list_log_issues` — what is failing, and how often. A fatal error |
| 751 |
with a high count is the story; deprecation notices usually are not. |
| 752 |
2. `get_log_issue` — the stack trace for the one you are working on. |
| 753 |
Read it from the bottom up: the last frame is where it broke, the |
| 754 |
frames above it are who asked. |
| 755 |
3. `read_source_excerpt` — the actual code at that line, and at the |
| 756 |
caller if the line alone does not explain it. Never describe code you |
| 757 |
have not read. |
| 758 |
4. `get_site_context` — versions and debug flags. A large share of |
| 759 |
WordPress fatals are a PHP-version or plugin-version story. |
| 760 |
|
| 761 |
Then answer with: what is failing, in one sentence; whose code it is |
| 762 |
(name the plugin or theme, with its version); why it happens, citing the |
| 763 |
lines you read; and the change you would make, as a short diff or a |
| 764 |
precise "in FILE, line N, replace X with Y". |
| 765 |
|
| 766 |
**You can read this site but you cannot change it.** Every tool here is |
| 767 |
read-only — there is no ability that edits a file, deactivates a plugin, |
| 768 |
or runs an update. So propose the fix and let the user apply it. Never |
| 769 |
say you have fixed, changed, patched or disabled anything; if the safest |
| 770 |
next step is deactivating a plugin or rolling back a version, say so as |
| 771 |
a recommendation and let them decide. Where a fix carries risk — a |
| 772 |
change inside a plugin that an update will overwrite, an edit to a theme |
| 773 |
without a child theme — say that too. |
| 774 |
PROMPT; |
| 775 |
|
| 776 |
return '' === $appendix ? $skill : $appendix . "\n\n" . $skill; |
| 777 |
} |
| 778 |
add_filter( 'openstation_ai_system_prompt_appendix', 'openstation_ai_debug_prompt_appendix', 10, 2 ); |
| 779 |
|