@relvehq/agent-ready 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +338 -3
- package/SETUP.md +337 -0
- package/dist/bin/agent-ready.d.ts +23 -0
- package/dist/bin/agent-ready.js +350 -0
- package/dist/cli/init.d.ts +70 -0
- package/dist/cli/init.js +366 -0
- package/dist/core/accept.d.ts +8 -0
- package/dist/core/accept.js +76 -0
- package/dist/core/config.d.ts +145 -0
- package/dist/core/config.js +103 -0
- package/dist/core/convert.d.ts +22 -0
- package/dist/core/convert.js +334 -0
- package/dist/core/detect.d.ts +30 -0
- package/dist/core/detect.js +82 -0
- package/dist/core/easy.d.ts +19 -0
- package/dist/core/easy.js +117 -0
- package/dist/core/enrich.d.ts +30 -0
- package/dist/core/enrich.js +155 -0
- package/dist/core/llms.d.ts +11 -0
- package/dist/core/llms.js +71 -0
- package/dist/core/report.d.ts +15 -0
- package/dist/core/report.js +46 -0
- package/dist/core/resolve.d.ts +43 -0
- package/dist/core/resolve.js +204 -0
- package/dist/core/respond.d.ts +18 -0
- package/dist/core/respond.js +59 -0
- package/dist/core/sitemap.d.ts +31 -0
- package/dist/core/sitemap.js +140 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +76 -0
- package/dist/next/dashboard.d.ts +14 -0
- package/dist/next/dashboard.js +56 -0
- package/dist/next/index.d.ts +42 -0
- package/dist/next/index.js +100 -0
- package/dist/next/route.d.ts +26 -0
- package/dist/next/route.js +167 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +191 -0
- package/dist/proof/audit.d.ts +47 -0
- package/dist/proof/audit.js +253 -0
- package/dist/proof/bots.d.ts +24 -0
- package/dist/proof/bots.js +60 -0
- package/dist/proof/dashboard.d.ts +8 -0
- package/dist/proof/dashboard.js +77 -0
- package/dist/proof/hits.d.ts +18 -0
- package/dist/proof/hits.js +51 -0
- package/dist/proof/jsonl.d.ts +5 -0
- package/dist/proof/jsonl.js +80 -0
- package/dist/proof/stats.d.ts +39 -0
- package/dist/proof/stats.js +127 -0
- package/dist/refine/check.d.ts +17 -0
- package/dist/refine/check.js +134 -0
- package/dist/refine/extract.d.ts +7 -0
- package/dist/refine/extract.js +120 -0
- package/dist/refine/file-store.d.ts +6 -0
- package/dist/refine/file-store.js +87 -0
- package/dist/refine/model.d.ts +30 -0
- package/dist/refine/model.js +105 -0
- package/dist/refine/prompts.d.ts +49 -0
- package/dist/refine/prompts.js +178 -0
- package/dist/refine/refine.d.ts +38 -0
- package/dist/refine/refine.js +75 -0
- package/dist/refine/run.d.ts +52 -0
- package/dist/refine/run.js +66 -0
- package/dist/refine/types.d.ts +17 -0
- package/dist/refine/types.js +8 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +363 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +447 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +421 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +115 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +543 -0
- package/dist/wp/relve-agent-ready/includes/index.php +2 -0
- package/dist/wp/relve-agent-ready/index.php +2 -0
- package/dist/wp/relve-agent-ready/readme.txt +75 -0
- package/dist/wp/relve-agent-ready/relve-agent-ready.php +70 -0
- package/dist/wp/relve-agent-ready/uninstall.php +24 -0
- package/package.json +64 -6
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Store: refined Markdown files from Relve.
|
|
4
|
+
*
|
|
5
|
+
* Upload contract (agreed with the Relve runner, `venture.ts --zip`): the zip
|
|
6
|
+
* holds a top-level `md/` folder; inside it, `<url-path>.md` relative to the
|
|
7
|
+
* site root exactly as the npm refine writes them ("/" -> md/index.md,
|
|
8
|
+
* "/blog/my-post/" -> md/blog/my-post.md). `.json` sidecars and other files
|
|
9
|
+
* are skipped, not errors. Entries outside `md/`, absolute paths, `..` and
|
|
10
|
+
* oversized files are rejected.
|
|
11
|
+
*
|
|
12
|
+
* Files live in wp-content/uploads/relve-agent-ready/md/, behind an
|
|
13
|
+
* .htaccess "deny all" and index.php files. They are only ever read and sent
|
|
14
|
+
* as text, never included or executed.
|
|
15
|
+
*
|
|
16
|
+
* The validation and lookup helpers are pure and unit-tested; the class takes
|
|
17
|
+
* its base directory in the constructor so tests use a temp dir.
|
|
18
|
+
*
|
|
19
|
+
* @package Relve_Agent_Ready
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
23
|
+
exit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Refined-files store.
|
|
28
|
+
*/
|
|
29
|
+
class Relve_AR_Store {
|
|
30
|
+
|
|
31
|
+
/** Max zip size in bytes. */
|
|
32
|
+
const MAX_ZIP_BYTES = 20971520; // 20 MB.
|
|
33
|
+
|
|
34
|
+
/** Max size of one Markdown file in bytes. */
|
|
35
|
+
const MAX_FILE_BYTES = 2097152; // 2 MB.
|
|
36
|
+
|
|
37
|
+
/** Max total uncompressed bytes written from one zip. */
|
|
38
|
+
const MAX_TOTAL_BYTES = 104857600; // 100 MB.
|
|
39
|
+
|
|
40
|
+
/** Max entries read from one zip. */
|
|
41
|
+
const MAX_ENTRIES = 20000;
|
|
42
|
+
|
|
43
|
+
/** Root folder: .../uploads/relve-agent-ready. @var string */
|
|
44
|
+
private $root;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Constructor.
|
|
48
|
+
*
|
|
49
|
+
* @param string|null $root Root folder; default uploads/relve-agent-ready.
|
|
50
|
+
*/
|
|
51
|
+
public function __construct( $root = null ) {
|
|
52
|
+
if ( null === $root ) {
|
|
53
|
+
$uploads = wp_upload_dir( null, false );
|
|
54
|
+
$root = trailingslashit( $uploads['basedir'] ) . 'relve-agent-ready';
|
|
55
|
+
}
|
|
56
|
+
$this->root = rtrim( str_replace( '\\', '/', (string) $root ), '/' );
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Root folder.
|
|
61
|
+
*
|
|
62
|
+
* @return string
|
|
63
|
+
*/
|
|
64
|
+
public function root() {
|
|
65
|
+
return $this->root;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Folder the .md files live in.
|
|
70
|
+
*
|
|
71
|
+
* @return string
|
|
72
|
+
*/
|
|
73
|
+
public function md_dir() {
|
|
74
|
+
return $this->root . '/md';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── validation (pure) ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify one zip entry name.
|
|
81
|
+
*
|
|
82
|
+
* @param string $name Entry name as stored in the zip.
|
|
83
|
+
* @return array { status: store|skip|reject, path: relative path under md/ (store only), reason: string }
|
|
84
|
+
*/
|
|
85
|
+
public static function validate_entry( $name ) {
|
|
86
|
+
$name = (string) $name;
|
|
87
|
+
$skip = function ( $reason ) {
|
|
88
|
+
return array(
|
|
89
|
+
'status' => 'skip',
|
|
90
|
+
'path' => '',
|
|
91
|
+
'reason' => $reason,
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
$reject = function ( $reason ) {
|
|
95
|
+
return array(
|
|
96
|
+
'status' => 'reject',
|
|
97
|
+
'path' => '',
|
|
98
|
+
'reason' => $reason,
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
if ( '' === $name || false !== strpos( $name, "\0" ) || preg_match( '/[\x00-\x1F\x7F]/', $name ) ) {
|
|
102
|
+
return $reject( 'bad name' );
|
|
103
|
+
}
|
|
104
|
+
if ( false !== strpos( $name, '\\' ) ) {
|
|
105
|
+
return $reject( 'backslash in path' );
|
|
106
|
+
}
|
|
107
|
+
if ( '/' === $name[0] || preg_match( '#^[A-Za-z]:#', $name ) ) {
|
|
108
|
+
return $reject( 'absolute path' );
|
|
109
|
+
}
|
|
110
|
+
$segments = explode( '/', $name );
|
|
111
|
+
foreach ( $segments as $seg ) {
|
|
112
|
+
if ( '..' === $seg || '.' === $seg ) {
|
|
113
|
+
return $reject( 'path traversal' );
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if ( '/' === substr( $name, -1 ) ) {
|
|
117
|
+
return $skip( 'folder' );
|
|
118
|
+
}
|
|
119
|
+
if ( 'md' !== $segments[0] || count( $segments ) < 2 ) {
|
|
120
|
+
return $reject( 'outside md/' );
|
|
121
|
+
}
|
|
122
|
+
$rel = implode( '/', array_slice( $segments, 1 ) );
|
|
123
|
+
if ( strtolower( substr( $rel, -3 ) ) !== '.md' ) {
|
|
124
|
+
return $skip( 'not a .md file' );
|
|
125
|
+
}
|
|
126
|
+
foreach ( array_slice( $segments, 1 ) as $seg ) {
|
|
127
|
+
if ( '' === $seg ) {
|
|
128
|
+
return $reject( 'empty path segment' );
|
|
129
|
+
}
|
|
130
|
+
if ( '.' === $seg[0] ) {
|
|
131
|
+
return $reject( 'hidden file' );
|
|
132
|
+
}
|
|
133
|
+
if ( preg_match( '#[<>:"|?*]#', $seg ) ) {
|
|
134
|
+
return $reject( 'bad character' );
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if ( strlen( $rel ) > 1024 ) {
|
|
138
|
+
return $reject( 'path too long' );
|
|
139
|
+
}
|
|
140
|
+
return array(
|
|
141
|
+
'status' => 'store',
|
|
142
|
+
'path' => $rel,
|
|
143
|
+
'reason' => '',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Relative file names to try for a request path, most specific first:
|
|
149
|
+
* the full path, then the path relative to the home path.
|
|
150
|
+
*
|
|
151
|
+
* @param string $path Normalised request path ("/blog/my-post", "/").
|
|
152
|
+
* @param string $home_path Home path without trailing slash ("" or "/blog").
|
|
153
|
+
* @return string[] e.g. array( 'blog/my-post.md', 'my-post.md' ).
|
|
154
|
+
*/
|
|
155
|
+
public static function candidates( $path, $home_path = '' ) {
|
|
156
|
+
$norm = Relve_AR_Negotiation::normalize_path( $path );
|
|
157
|
+
if ( null === $norm ) {
|
|
158
|
+
return array();
|
|
159
|
+
}
|
|
160
|
+
$list = array( self::file_for( $norm ) );
|
|
161
|
+
if ( '' !== $home_path && ( $norm === $home_path || 0 === strpos( $norm, $home_path . '/' ) ) ) {
|
|
162
|
+
$rel = substr( $norm, strlen( $home_path ) );
|
|
163
|
+
$list[] = self::file_for( '' === $rel ? '/' : $rel );
|
|
164
|
+
}
|
|
165
|
+
return array_values( array_unique( $list ) );
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* "/" -> "index.md", "/a/b" -> "a/b.md".
|
|
170
|
+
*
|
|
171
|
+
* @param string $norm Normalised path.
|
|
172
|
+
* @return string
|
|
173
|
+
*/
|
|
174
|
+
private static function file_for( $norm ) {
|
|
175
|
+
$clean = trim( $norm, '/' );
|
|
176
|
+
return ( '' === $clean ? 'index' : $clean ) . '.md';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── reading ───────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stored Markdown for a request path, or null.
|
|
183
|
+
*
|
|
184
|
+
* @param string $path Normalised request path.
|
|
185
|
+
* @param string $home_path Home path.
|
|
186
|
+
* @return string|null
|
|
187
|
+
*/
|
|
188
|
+
public function get( $path, $home_path = '' ) {
|
|
189
|
+
foreach ( self::candidates( $path, $home_path ) as $rel ) {
|
|
190
|
+
$file = $this->md_dir() . '/' . $rel;
|
|
191
|
+
if ( ! $this->inside( $file ) || ! is_file( $file ) || ! is_readable( $file ) ) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
$size = filesize( $file );
|
|
195
|
+
if ( false === $size || $size > self::MAX_FILE_BYTES ) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
$text = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file.
|
|
199
|
+
if ( false !== $text && '' !== trim( $text ) ) {
|
|
200
|
+
return $text;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Is $file inside the md folder (after resolving symlinks)?
|
|
208
|
+
*
|
|
209
|
+
* @param string $file File path.
|
|
210
|
+
* @return bool
|
|
211
|
+
*/
|
|
212
|
+
private function inside( $file ) {
|
|
213
|
+
$real_dir = realpath( $this->md_dir() );
|
|
214
|
+
$real = realpath( $file );
|
|
215
|
+
if ( false === $real_dir || false === $real ) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
$real_dir = rtrim( str_replace( '\\', '/', $real_dir ), '/' ) . '/';
|
|
219
|
+
return 0 === strpos( str_replace( '\\', '/', $real ), $real_dir );
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Number of stored .md files.
|
|
224
|
+
*
|
|
225
|
+
* @return int
|
|
226
|
+
*/
|
|
227
|
+
public function count() {
|
|
228
|
+
$dir = $this->md_dir();
|
|
229
|
+
if ( ! is_dir( $dir ) ) {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
$n = 0;
|
|
233
|
+
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
|
|
234
|
+
foreach ( $it as $f ) {
|
|
235
|
+
if ( $f->isFile() && strtolower( substr( $f->getFilename(), -3 ) ) === '.md' ) {
|
|
236
|
+
++$n;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return $n;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── writing ───────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create the folders and the protection files.
|
|
246
|
+
*
|
|
247
|
+
* @return bool
|
|
248
|
+
*/
|
|
249
|
+
public function ensure() {
|
|
250
|
+
foreach ( array( $this->root, $this->md_dir() ) as $dir ) {
|
|
251
|
+
if ( ! is_dir( $dir ) && ! self::mkdir_p( $dir ) ) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
if ( ! file_exists( $dir . '/index.php' ) ) {
|
|
255
|
+
file_put_contents( $dir . '/index.php', "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
$htaccess = $this->root . '/.htaccess';
|
|
259
|
+
if ( ! file_exists( $htaccess ) ) {
|
|
260
|
+
$rules = "# Relve Agent Ready: refined files are served by WordPress, never directly.\n"
|
|
261
|
+
. "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n"
|
|
262
|
+
. "<IfModule !mod_authz_core.c>\n\tOrder deny,allow\n\tDeny from all\n</IfModule>\n";
|
|
263
|
+
file_put_contents( $htaccess, $rules ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Write one validated file.
|
|
270
|
+
*
|
|
271
|
+
* @param string $rel Relative path from validate_entry().
|
|
272
|
+
* @param string $content Markdown.
|
|
273
|
+
* @return bool
|
|
274
|
+
*/
|
|
275
|
+
public function put( $rel, $content ) {
|
|
276
|
+
$check = self::validate_entry( 'md/' . $rel );
|
|
277
|
+
if ( 'store' !== $check['status'] || strlen( $content ) > self::MAX_FILE_BYTES ) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
$file = $this->md_dir() . '/' . $check['path'];
|
|
281
|
+
$dir = dirname( $file );
|
|
282
|
+
if ( ! is_dir( $dir ) && ! self::mkdir_p( $dir ) ) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
if ( ! $this->inside( $dir ) && realpath( $dir ) !== realpath( $this->md_dir() ) ) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return false !== file_put_contents( $file, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Delete every stored file (keeps the folder and its protection files).
|
|
293
|
+
*
|
|
294
|
+
* @return void
|
|
295
|
+
*/
|
|
296
|
+
public function clear() {
|
|
297
|
+
self::rmdir_r( $this->md_dir() );
|
|
298
|
+
$this->ensure();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Delete the whole root folder (uninstall).
|
|
303
|
+
*
|
|
304
|
+
* @return void
|
|
305
|
+
*/
|
|
306
|
+
public function delete_all() {
|
|
307
|
+
self::rmdir_r( $this->root );
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Import a zip: replaces the stored files with the zip's md/ files.
|
|
312
|
+
*
|
|
313
|
+
* @param string $zip_file Path of the uploaded zip.
|
|
314
|
+
* @return array { ok: bool, error: string, stored: int, skipped: int, rejected: int, reasons: string[] }
|
|
315
|
+
*/
|
|
316
|
+
public function import_zip( $zip_file ) {
|
|
317
|
+
$result = array(
|
|
318
|
+
'ok' => false,
|
|
319
|
+
'error' => '',
|
|
320
|
+
'stored' => 0,
|
|
321
|
+
'skipped' => 0,
|
|
322
|
+
'rejected' => 0,
|
|
323
|
+
'reasons' => array(),
|
|
324
|
+
);
|
|
325
|
+
$size = is_file( $zip_file ) ? filesize( $zip_file ) : false;
|
|
326
|
+
if ( false === $size || 0 === $size ) {
|
|
327
|
+
$result['error'] = 'The upload is empty or missing.';
|
|
328
|
+
return $result;
|
|
329
|
+
}
|
|
330
|
+
if ( $size > self::MAX_ZIP_BYTES ) {
|
|
331
|
+
$result['error'] = 'The zip is larger than 20 MB.';
|
|
332
|
+
return $result;
|
|
333
|
+
}
|
|
334
|
+
$files = class_exists( 'ZipArchive' ) ? $this->read_zip_archive( $zip_file, $result ) : $this->read_zip_fallback( $zip_file, $result );
|
|
335
|
+
if ( null === $files ) {
|
|
336
|
+
return $result;
|
|
337
|
+
}
|
|
338
|
+
if ( ! $files ) {
|
|
339
|
+
$result['error'] = 'No .md files under md/ were found in the zip. Existing files were kept.';
|
|
340
|
+
return $result;
|
|
341
|
+
}
|
|
342
|
+
// Replace the set: a refined run is always complete.
|
|
343
|
+
$this->clear();
|
|
344
|
+
foreach ( $files as $rel => $content ) {
|
|
345
|
+
if ( $this->put( $rel, $content ) ) {
|
|
346
|
+
++$result['stored'];
|
|
347
|
+
} else {
|
|
348
|
+
++$result['rejected'];
|
|
349
|
+
$result['reasons'][] = $rel . ': could not be written';
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
$result['ok'] = $result['stored'] > 0;
|
|
353
|
+
if ( ! $result['ok'] ) {
|
|
354
|
+
$result['error'] = 'No file could be written. Check that wp-content/uploads is writable.';
|
|
355
|
+
}
|
|
356
|
+
return $result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Count one classified entry; return the content to keep or null.
|
|
361
|
+
*
|
|
362
|
+
* @param array $check From validate_entry().
|
|
363
|
+
* @param string $name Entry name.
|
|
364
|
+
* @param int $size Uncompressed size.
|
|
365
|
+
* @param array $result Result (counts updated).
|
|
366
|
+
* @param int $total Running total of accepted bytes.
|
|
367
|
+
* @return bool Whether to read the entry.
|
|
368
|
+
*/
|
|
369
|
+
private static function accept_entry( $check, $name, $size, &$result, &$total ) {
|
|
370
|
+
if ( 'skip' === $check['status'] ) {
|
|
371
|
+
if ( 'folder' !== $check['reason'] ) {
|
|
372
|
+
++$result['skipped'];
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
if ( 'reject' === $check['status'] ) {
|
|
377
|
+
++$result['rejected'];
|
|
378
|
+
$result['reasons'][] = $name . ': ' . $check['reason'];
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if ( $size > self::MAX_FILE_BYTES ) {
|
|
382
|
+
++$result['rejected'];
|
|
383
|
+
$result['reasons'][] = $name . ': larger than 2 MB';
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
if ( $total + $size > self::MAX_TOTAL_BYTES ) {
|
|
387
|
+
++$result['rejected'];
|
|
388
|
+
$result['reasons'][] = $name . ': over the 100 MB total';
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
$total += $size;
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Check one entry's content: size and valid UTF-8.
|
|
397
|
+
*
|
|
398
|
+
* @param string|false $content Content.
|
|
399
|
+
* @param string $name Entry name.
|
|
400
|
+
* @param array $result Result.
|
|
401
|
+
* @return bool
|
|
402
|
+
*/
|
|
403
|
+
private static function content_ok( $content, $name, &$result ) {
|
|
404
|
+
if ( false === $content || strlen( $content ) > self::MAX_FILE_BYTES ) {
|
|
405
|
+
++$result['rejected'];
|
|
406
|
+
$result['reasons'][] = $name . ': unreadable or too large';
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
if ( ! preg_match( '//u', $content ) ) {
|
|
410
|
+
++$result['rejected'];
|
|
411
|
+
$result['reasons'][] = $name . ': not UTF-8 text';
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Read entries with ZipArchive.
|
|
419
|
+
*
|
|
420
|
+
* @param string $zip_file Zip.
|
|
421
|
+
* @param array $result Result.
|
|
422
|
+
* @return array|null rel => content, or null on a fatal error.
|
|
423
|
+
*/
|
|
424
|
+
private function read_zip_archive( $zip_file, &$result ) {
|
|
425
|
+
$zip = new ZipArchive();
|
|
426
|
+
if ( true !== $zip->open( $zip_file ) ) {
|
|
427
|
+
$result['error'] = 'The file is not a valid zip.';
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
$files = array();
|
|
431
|
+
$total = 0;
|
|
432
|
+
$count = min( $zip->numFiles, self::MAX_ENTRIES );
|
|
433
|
+
for ( $i = 0; $i < $count; $i++ ) {
|
|
434
|
+
$stat = $zip->statIndex( $i );
|
|
435
|
+
if ( ! $stat ) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
$name = $stat['name'];
|
|
439
|
+
$check = self::validate_entry( $name );
|
|
440
|
+
if ( ! self::accept_entry( $check, $name, (int) $stat['size'], $result, $total ) ) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
$content = $zip->getFromIndex( $i, self::MAX_FILE_BYTES + 1 );
|
|
444
|
+
if ( self::content_ok( $content, $name, $result ) ) {
|
|
445
|
+
$files[ $check['path'] ] = $content;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if ( $zip->numFiles > self::MAX_ENTRIES ) {
|
|
449
|
+
$result['reasons'][] = 'Only the first ' . self::MAX_ENTRIES . ' entries were read.';
|
|
450
|
+
}
|
|
451
|
+
$zip->close();
|
|
452
|
+
return $files;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Read entries with WordPress's unzip_file() (PclZip) when ZipArchive is
|
|
457
|
+
* missing: extract to a temp folder, then validate every extracted path
|
|
458
|
+
* exactly as zip entry names.
|
|
459
|
+
*
|
|
460
|
+
* @param string $zip_file Zip.
|
|
461
|
+
* @param array $result Result.
|
|
462
|
+
* @return array|null
|
|
463
|
+
*/
|
|
464
|
+
private function read_zip_fallback( $zip_file, &$result ) {
|
|
465
|
+
if ( ! function_exists( 'unzip_file' ) || ! function_exists( 'WP_Filesystem' ) ) {
|
|
466
|
+
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
467
|
+
}
|
|
468
|
+
if ( ! WP_Filesystem() ) {
|
|
469
|
+
$result['error'] = 'Could not access the file system to unzip.';
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
$tmp = $this->root . '/tmp-' . wp_generate_password( 12, false );
|
|
473
|
+
if ( ! self::mkdir_p( $tmp ) ) {
|
|
474
|
+
$result['error'] = 'Could not create a temporary folder.';
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
$unzipped = unzip_file( $zip_file, $tmp );
|
|
478
|
+
if ( is_wp_error( $unzipped ) ) {
|
|
479
|
+
self::rmdir_r( $tmp );
|
|
480
|
+
$result['error'] = 'The file is not a valid zip.';
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
$files = array();
|
|
484
|
+
$total = 0;
|
|
485
|
+
$seen = 0;
|
|
486
|
+
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $tmp, FilesystemIterator::SKIP_DOTS ) );
|
|
487
|
+
foreach ( $it as $f ) {
|
|
488
|
+
if ( ! $f->isFile() || ++$seen > self::MAX_ENTRIES ) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
$name = ltrim( str_replace( '\\', '/', substr( $f->getPathname(), strlen( $tmp ) ) ), '/' );
|
|
492
|
+
$check = self::validate_entry( $name );
|
|
493
|
+
if ( ! self::accept_entry( $check, $name, (int) $f->getSize(), $result, $total ) ) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
$content = file_get_contents( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
|
497
|
+
if ( self::content_ok( $content, $name, $result ) ) {
|
|
498
|
+
$files[ $check['path'] ] = $content;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
self::rmdir_r( $tmp );
|
|
502
|
+
return $files;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ── filesystem helpers ────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Recursive mkdir.
|
|
509
|
+
*
|
|
510
|
+
* @param string $dir Folder.
|
|
511
|
+
* @return bool
|
|
512
|
+
*/
|
|
513
|
+
private static function mkdir_p( $dir ) {
|
|
514
|
+
if ( function_exists( 'wp_mkdir_p' ) ) {
|
|
515
|
+
return wp_mkdir_p( $dir );
|
|
516
|
+
}
|
|
517
|
+
return is_dir( $dir ) || mkdir( $dir, 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Recursive delete. Does not follow symlinks.
|
|
522
|
+
*
|
|
523
|
+
* @param string $dir Folder.
|
|
524
|
+
* @return void
|
|
525
|
+
*/
|
|
526
|
+
private static function rmdir_r( $dir ) {
|
|
527
|
+
if ( ! is_dir( $dir ) || is_link( $dir ) ) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
$it = new RecursiveIteratorIterator(
|
|
531
|
+
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
|
532
|
+
RecursiveIteratorIterator::CHILD_FIRST
|
|
533
|
+
);
|
|
534
|
+
foreach ( $it as $f ) {
|
|
535
|
+
if ( $f->isDir() && ! $f->isLink() ) {
|
|
536
|
+
rmdir( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
|
|
537
|
+
} else {
|
|
538
|
+
unlink( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
=== Relve Agent Ready ===
|
|
2
|
+
Contributors: relve
|
|
3
|
+
Tags: markdown, llms.txt, ai, agents, seo
|
|
4
|
+
Requires at least: 6.0
|
|
5
|
+
Tested up to: 6.7
|
|
6
|
+
Requires PHP: 7.4
|
|
7
|
+
Stable tag: 0.1.0
|
|
8
|
+
License: MIT
|
|
9
|
+
License URI: https://opensource.org/licenses/MIT
|
|
10
|
+
|
|
11
|
+
Serve a clean Markdown version of your posts and pages to AI agents, plus an llms.txt. Browsers always get the normal page.
|
|
12
|
+
|
|
13
|
+
== Description ==
|
|
14
|
+
|
|
15
|
+
AI agents and coding assistants (Claude Code, Cursor, Copilot and others) read the web better as Markdown than as themed HTML. Relve Agent Ready answers them with Markdown built from WordPress's own data, while every browser keeps getting your normal page.
|
|
16
|
+
|
|
17
|
+
An agent can ask in three ways:
|
|
18
|
+
|
|
19
|
+
* `Accept: text/markdown` on any post or page URL, when it prefers Markdown over (or equally to) HTML. A browser's Accept header, `*/*`, or no header never gets Markdown. The user agent is never used to decide.
|
|
20
|
+
* `<permalink>.md`, for example `https://example.com/my-post.md` (the home page is `/index.md`).
|
|
21
|
+
* `<permalink>?format=md`.
|
|
22
|
+
|
|
23
|
+
What it serves:
|
|
24
|
+
|
|
25
|
+
* Posts and pages (published, public, not password-protected, of the post types you enable): YAML front matter (title, canonical, author, published, modified, categories, tags, description, source), one `# Title` heading, the featured image and the content converted to Markdown (headings, lists, links, images, tables, code blocks).
|
|
26
|
+
* The posts front page and category, tag and author archives: a heading and a list of their latest 50 posts.
|
|
27
|
+
* `llms.txt` under your home path: your site name, tagline and links to the `.md` version of up to 200 recent posts and pages.
|
|
28
|
+
* Refined files: upload the zip Relve produces for your site and those hand-checked Markdown files are served first.
|
|
29
|
+
|
|
30
|
+
Response headers: `Content-Type: text/markdown; charset=utf-8`, `Vary: Accept`, `Link: <permalink>; rel="canonical"`, `X-Robots-Tag: noindex` (search engines keep indexing your HTML page), `x-markdown-tokens`, `ETag` (304 on `If-None-Match`), `x-agent-ready-source: native | stored | llms`. Markdown asked for by `Accept` is sent with `Cache-Control: private, max-age=300`; `.md`, `?format=md` and llms.txt with `Cache-Control: public, s-maxage=300, stale-while-revalidate=86400`.
|
|
31
|
+
|
|
32
|
+
The plugin is installed switched off. Nothing changes until you tick "Enabled" in Settings -> Agent Ready.
|
|
33
|
+
|
|
34
|
+
== Installation ==
|
|
35
|
+
|
|
36
|
+
1. Upload the `relve-agent-ready` zip in Plugins -> Add New -> Upload Plugin, then activate it.
|
|
37
|
+
2. Go to Settings -> Agent Ready, tick "Enabled" and save.
|
|
38
|
+
3. Optional: choose post types, add excluded paths, pick the llms.txt mode.
|
|
39
|
+
4. Optional: upload the refined-files zip Relve sent you.
|
|
40
|
+
5. Run the three test commands shown in the Status panel.
|
|
41
|
+
|
|
42
|
+
== Frequently Asked Questions ==
|
|
43
|
+
|
|
44
|
+
= Will this change what my visitors or Google see? =
|
|
45
|
+
|
|
46
|
+
No. Browsers send an Accept header that prefers HTML, so they get your normal page. Markdown responses carry `X-Robots-Tag: noindex` and a canonical link to the HTML page.
|
|
47
|
+
|
|
48
|
+
= My site sits behind Cloudflare, a Worker or another proxy. What must it pass through? =
|
|
49
|
+
|
|
50
|
+
The `Accept` header, paths ending in `.md`, the `format` query parameter and `/llms.txt` (under your home path) must reach WordPress unchanged. If the proxy caches pages, it should either include `Accept` in its cache key or not cache responses marked `Cache-Control: private`. `?format=md` works through any proxy that forwards the query string.
|
|
51
|
+
|
|
52
|
+
= I use WP Rocket or another page cache. =
|
|
53
|
+
|
|
54
|
+
The plugin sets `DONOTCACHEPAGE` on every Markdown response and tells WP Rocket never to cache `.md` URLs or llms.txt. A page cache that already holds the HTML of a page may still answer an `Accept: text/markdown` request with that HTML. That is safe (the agent gets the normal page); the `.md` URL always returns Markdown.
|
|
55
|
+
|
|
56
|
+
= I use Yoast SEO. =
|
|
57
|
+
|
|
58
|
+
The description in the front matter uses your Yoast meta description when there is one. If Yoast's own llms.txt feature is on, the "auto" mode leaves llms.txt to Yoast; choose "On" to serve this plugin's version instead.
|
|
59
|
+
|
|
60
|
+
= What is in the refined-files zip? =
|
|
61
|
+
|
|
62
|
+
A top-level `md/` folder with one `.md` file per URL path (`/` is `md/index.md`, `/blog/my-post/` is `md/blog/my-post.md`). Other files (such as `.json` sidecars) are skipped. Entries outside `md/`, absolute paths and paths containing `..` are rejected. Uploading replaces the stored set. Files are kept in `wp-content/uploads/relve-agent-ready/md/`, behind a deny-all `.htaccess`, and are only ever sent as text.
|
|
63
|
+
|
|
64
|
+
= Which pages are never served? =
|
|
65
|
+
|
|
66
|
+
Drafts, private and password-protected posts, post types you did not enable, search results, feeds, 404s and any path in the excluded list (by default `/wp-admin`, `/wp-login.php`, `/wp-json`, `/feed`, `/cart`, `/checkout`, `/my-account`, `/account` and WordPress's own folders). An `Accept` request for those gets WordPress's normal response; a `.md` or `?format=md` request gets a plain-text 404.
|
|
67
|
+
|
|
68
|
+
= What happens when I uninstall? =
|
|
69
|
+
|
|
70
|
+
Deactivating flushes rewrite rules and removes the WP Rocket rules. Deleting the plugin also removes its settings and the refined-files folder.
|
|
71
|
+
|
|
72
|
+
== Changelog ==
|
|
73
|
+
|
|
74
|
+
= 0.1.0 =
|
|
75
|
+
* First release: Markdown for posts, pages, the front page and archives via Accept, `.md` and `?format=md`; llms.txt; refined-files upload; WP Rocket and Yoast awareness; settings page with status and test commands.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Plugin Name: Relve Agent Ready
|
|
4
|
+
* Plugin URI: https://relvehq.com/agent-md
|
|
5
|
+
* Description: Serves a Markdown version of posts, pages and archives to AI agents (Accept: text/markdown, .md URLs, ?format=md) plus an llms.txt. Browsers are never affected. Off until enabled in Settings -> Agent Ready.
|
|
6
|
+
* Version: 0.1.0
|
|
7
|
+
* Requires at least: 6.0
|
|
8
|
+
* Requires PHP: 7.4
|
|
9
|
+
* Author: Relve
|
|
10
|
+
* Author URI: https://relvehq.com
|
|
11
|
+
* License: MIT
|
|
12
|
+
* License URI: https://opensource.org/licenses/MIT
|
|
13
|
+
* Text Domain: relve-agent-ready
|
|
14
|
+
*
|
|
15
|
+
* @package Relve_Agent_Ready
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
19
|
+
exit;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
define( 'RELVE_AR_VERSION', '0.1.0' );
|
|
23
|
+
define( 'RELVE_AR_FILE', __FILE__ );
|
|
24
|
+
define( 'RELVE_AR_DIR', plugin_dir_path( __FILE__ ) );
|
|
25
|
+
|
|
26
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-settings.php';
|
|
27
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-negotiation.php';
|
|
28
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-converter.php';
|
|
29
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-markdown-builder.php';
|
|
30
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-response.php';
|
|
31
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-llms.php';
|
|
32
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-store.php';
|
|
33
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-router.php';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Activation: default settings (disabled), the refined-files folder with its
|
|
37
|
+
* protection files, fresh rewrite rules and WP Rocket config.
|
|
38
|
+
*
|
|
39
|
+
* @return void
|
|
40
|
+
*/
|
|
41
|
+
function relve_ar_activate() {
|
|
42
|
+
add_option( Relve_AR_Settings::OPTION, Relve_AR_Settings::defaults() );
|
|
43
|
+
$store = new Relve_AR_Store();
|
|
44
|
+
$store->ensure();
|
|
45
|
+
flush_rewrite_rules();
|
|
46
|
+
add_filter( 'rocket_cache_reject_uri', array( 'Relve_AR_Router', 'rocket_reject_uri' ) );
|
|
47
|
+
Relve_AR_Router::refresh_rocket();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Deactivation: flush rewrite rules and drop our WP Rocket reject rules.
|
|
52
|
+
* Settings and refined files are kept (uninstall removes them).
|
|
53
|
+
*
|
|
54
|
+
* @return void
|
|
55
|
+
*/
|
|
56
|
+
function relve_ar_deactivate() {
|
|
57
|
+
flush_rewrite_rules();
|
|
58
|
+
remove_filter( 'rocket_cache_reject_uri', array( 'Relve_AR_Router', 'rocket_reject_uri' ) );
|
|
59
|
+
Relve_AR_Router::refresh_rocket();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
register_activation_hook( __FILE__, 'relve_ar_activate' );
|
|
63
|
+
register_deactivation_hook( __FILE__, 'relve_ar_deactivate' );
|
|
64
|
+
|
|
65
|
+
Relve_AR_Router::init();
|
|
66
|
+
|
|
67
|
+
if ( is_admin() ) {
|
|
68
|
+
require_once RELVE_AR_DIR . 'includes/class-relve-ar-admin.php';
|
|
69
|
+
Relve_AR_Admin::init();
|
|
70
|
+
}
|