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

abilities-debugging.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.8, at includes/ai-copilot/abilities-debugging.php

779 lines 29.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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