Problem: Internal linking is the SEO lever with the longest payoff curve. Writers don't do it because finding related content + drafting anchor text that reads naturally is two hours of work per post.
Solution: A "Suggest Internal Links" button in the post editor. One click → top N related pages by embedding similarity → editor picks 1–5 targets → LLM drafts anchor + paragraph rewrite per target → editor approves → applied to post_content.
| File | Lines | Purpose |
|---|---|---|
context-linker.php | 68 | Bootstrap. Version constant, plugin header, autoload includes/. |
includes/class-installer.php | 115 | Schema ({prefix}context_linker_embeddings), default settings, cron registration. |
includes/class-logger.php | 87 | Centralized Context_Linker_Logger::log() — writes to WP option context_linker_log. Belt-and-braces logging on every silent skip was the single biggest debug win. |
includes/class-embedder.php | 174 | OpenAI embeddings HTTP + cosine math. Hard char cap (24000 ≈ 7000 tokens) so reindex never trips OpenAI's 8192-token limit. |
includes/class-embeddings.php | 701 | Queue + CRUD + similarity search. Packed float32 binary storage with magic header 0xC0DE + uint16 count. dequeue_batch() pops N items, leaves rest. |
includes/class-batch.php | 284 | Cron tick + reindex_all. Drain loop: pop N → embed → repeat → ceiling at 500 items/tick. |
includes/class-suggestor.php | 1490 | The brain. Two-stage API: get_candidates_for_selection() + generate_anchors_for_selected(). Polarity check, validation, apply. |
includes/class-admin.php | 224 | Settings page + meta box registration. |
includes/class-ajax.php | 634 | AJAX + REST. Critical: uses bracket-notation flat-params, never JSON.stringify. |
templates/settings-page.php | 301 | Settings UI (OpenAI key, post types, taxonomies, max suggestions, etc.). |
assets/js/editor.js | 904 | Vanilla JS for the meta box UI. 2-stage UX (pick targets, then approve anchors). |
assets/css/editor.css + admin.css | 496 + 78 | Styles. |
CREATE TABLE {prefix}context_linker_embeddings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source_id BIGINT UNSIGNED NOT NULL,
source_type VARCHAR(40) NOT NULL, -- 'post' | 'page' | 'product' | 'term' | 'brand'
source_title TEXT,
source_url TEXT,
source_excerpt LONGTEXT,
embedding LONGTEXT NOT NULL, -- float32 packed, magic 0xC0DE + count
model VARCHAR(60) NOT NULL, -- e.g. 'text-embedding-3-small'
hash CHAR(40) NOT NULL, -- sha1 of embedded text; skip unchanged items
updated_at DATETIME NOT NULL,
UNIQUE KEY uniq_source (source_type, source_id)
);
| Method | Purpose | AJAX action |
|---|---|---|
Context_Linker_Suggestor::get_candidates_for_selection($post_id, $args) | Stage 1: top N related pages by similarity | wp_ajax_context_linker_candidates |
Context_Linker_Suggestor::generate_anchors_for_selected($post_id, $picks) | Stage 2: anchor + paragraph rewrite per picked target | wp_ajax_context_linker_generate |
Context_Linker_Suggestor::apply_to_post($post_id, $suggestions) | Server-side apply — paragraph replace + anchor insertion | wp_ajax_context_linker_apply, wp_ajax_context_linker_apply_one |
Context_Linker_Embeddings::enqueue($source_type, $source_id) | Add to reindex queue (called on save_post hook) | wp_ajax_context_linker_enqueue_post |
Context_Linker_Batch::tick() | Drain queue, embed, update table | cron (every 30s) + manual wp_ajax_context_linker_run_batch |
Context_Linker_Installer::reindex_all() | Wipe + re-queue everything | wp_ajax_context_linker_reindex |
Context_Linker_Embeddings::diversified_search($vector, $args) | MMR-diversified similarity search (lambda=0.7) | (internal) |
Context_Linker_Suggestor::suggest() is kept as a deprecated wrapper that runs both stages. Old AJAX action context_linker_suggest still works (logs a deprecation warning to Context_Linker_Logger).
pack('g', ...) silent zero-vector storageSymptom: Cosine similarity returned 0.0 for every pair, including duplicate content.
Root cause: PHP's g/G pack formats are "machine-dependent size and representation." On some PHP builds (specific glibc/libm combos), pack('g', $v) then unpack('g', ...) returns all zeros.
Fix: Switched to pack('E', ...) (big-endian IEEE 754 float64 — explicit, not machine-dependent). Added migration that re-embeds any row whose unpacked vector is all zeros.
e/E/f/g/G pack formats. Use explicit byte order + explicit width.Symptom: Uncaught Error: syntax error, unexpected token "public", expecting end of file.
Root cause: Edit tool's "match exact text" replacement matched the closing brace of a method AND the closing brace of the class. Phantom } before public function ….
Fix: Always re-read the file with read before editing nested braces. Match a unique chunk with 5+ lines of context above/below.
oldText match must include surrounding scope, not just the line you're changing.temperature: 0.2 rejected by newer OpenAI modelsSymptom: Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported.
Root cause: gpt-4o-mini, o1, o3, gpt-5 lock temperature to 1.
Fix: Hardcoded a list of locked models/prefixes in model_supports_temperature(); only sends temperature if the model supports it. Plus a retry-without-temperature if API still rejects.
Symptom: Old prompt "Generate an anchor phrase." LLM returned full sentences, paragraphs, emojis.
Fix: Closed-list "pick from these spans" prompt. PHP pre-extracts 2–8 word n-grams from the post that share vocabulary with the candidate target. LLM only picks an anchor_id. Server looks up the text by id.
JSON.stringify + PHP (array) cast silent skipSymptom: AJAX returned {applied: [], skipped: [...]}. JS marked everything "skipped" silently. User saw "Applied!" but content unchanged.
Root cause:
suggestions: JSON.stringify(approved) (a JSON string)(array) $_REQUEST['suggestions'] cast on a string returns ["<the JSON string>"] — a one-element array whose single member is the string itselfforeach ($suggestions as $s) got a string; $s['anchor'] was empty$applied stayed emptyif (!empty($applied)) skipped wp_update_post()Fix: Replaced JSON.stringify with bracket-notation flattening. flattenParams() recursively converts objects/arrays to PHP-style suggestions[0][anchor] keys. PHP reads them back as nested arrays.
JSON.stringify on the JS side. PHP reads nested $_REQUEST keys as flat strings. Use bracket notation: params['suggestions[' + i + '][anchor]'] = s.anchor.Symptom: Post_content in DB had the new <a> tags, but Gutenberg still showed old text. User concluded "nothing happened."
Root cause: Gutenberg has its own in-memory copy of post_content; doesn't reload from DB after server-side modification.
Fix: After successful apply, JS calls window.location.reload(). Also wp.data.dispatch('core/editor').resetEditorBlocks() to flush Gutenberg's internal state.
post_content from outside Gutenberg MUST trigger a reload or block reset. Otherwise the user sees "Applied!" but the editor looks unchanged.Symptom: Embedding API calls rejected for URL-heavy / code-heavy pages.
Root cause: v1.3.0 had wp_trim_words(1200) safety, but word counts ≠ token counts. 1000 words across 55,000 chars = 15k+ tokens.
Fix: Hard char cap MAX_EMBED_INPUT_CHARS = 24000 (~7000 tokens worst-case). Title + excerpt at top of payload so they always survive. Truncation snaps to last whitespace + … [truncated] marker.
Symptom: Reindex button only processed the first 25 items. Re-pressing re-queued the same set.
Root cause: tick() did dequeue_all() (drains entire queue) → array_slice($items, 0, $batch) (takes first 25) → process. Other 199 were never re-enqueued, never embedded, never visible in queue.
Fix: Added dequeue_batch($n) (pops first N, leaves rest). Refactored tick() to a drain loop with MAX_BATCHES_PER_TICK = 20 safety ceiling (500 items/tick max).
Symptom: Uncaught Error: Call to private method Context_Linker_Suggestor::candidate_for_prompt() from scope Context_Linker_Ajax.
Root cause: Method was private static in v1.3.0. class-ajax.php calls it for the editor's Find pages panel. PHP throws fatal on private from outside class scope.
Fix: Changed private static → public static. Method is pure data shaping, safe to be public.
self:: AND from another class, it should be public. private static means "same class only."Symptom (recurring, finally diagnosed): Second apply produces <at wine="" &="" spirit="" academy="" bangkok,="" …> wrapping entire paragraph, with every word becoming a per-word attribute.
Root cause: parse_placeholder() and placeholder_to_html() used greedy regex \[CL_ANCHOR\b[^\]]*\].*\[\/CL_ANCHOR\]. When LLM produced a rewritten_paragraph containing the new [CL_ANCHOR] placeholder AND kept an existing <a> tag (verbatim from post), the regex matched from first [CL_ANCHOR to last [/CL_ANCHOR], wrapping everything as "anchor text." WordPress's wp_kses_post then "fixed" the invalid HTML with literal [/CL_ANCHOR] text inside <a>, breaking the structure into per-word "attributes."
Fix:
.* → .*? (non-greedy) in both regexes.apply_one_option() refuses if rewritten_paragraph has != 1 placeholder.| Item | Status |
|---|---|
| Code state | Verified v1.3.4 in source AND in zip (both 58 KB, 15 entries) |
Deployed version on ai.tbs-staging.com | Unknown Last confirmed active was v1.0.5 (per Aug 20 memory). Alex does manual uploads. |
| Pending deploys to Alex | Possibly unconfirmed v1.0.6 → v1.3.4 may not all be live |
| Production deployment automation | None Alex uploads zips manually. Two paths proposed (SSH/rsync or git) — neither chosen. |
| Test harness | /tmp/test-drain3.php (11 tests for v1.3.2 batch logic). Other versions tested ad-hoc against the host. |
| Open bugs from user testing | None confirmed in 2026-08-21 memory. Last user test was v1.0.8 editor-reload fix. |
rebuild.sh — version bump + zip in one call. Currently you do bash package.sh from the plugin dir, which produces ../context-linker.zip. A wrapper that also bumps the version in context-linker.php would save a manual edit.ai.tbs-staging.com right now?~/clawd/work/context-linker/README.md — what the plugin does~/clawd/work/context-linker/context-linker.php — bootstrap, autoload~/clawd/work/context-linker/includes/class-installer.php — schema, settings, cron~/clawd/work/context-linker/includes/class-embeddings.php — storage + similarity~/clawd/work/context-linker/includes/class-suggestor.php — the brain (start at line 72: get_candidates_for_selection → 146: generate_anchors_for_selected → 1213: apply_to_post)~/clawd/work/context-linker/includes/class-ajax.php — HTTP surface~/clawd/work/context-linker/assets/js/editor.js — UX~/clawd/memory/2026-08-20.md + ~/clawd/memory/2026-08-21.md — bug history and lessonscd ~/clawd/work/context-linker
# 1. Bump version in context-linker.php header AND define() on line 20
# 2. Run the build
bash package.sh
# 3. Upload context-linker.zip to ai.tbs-staging.com via WP Admin → Plugins → Add New
# 4. Verify version on host matches what you built
Context_Linker_Logger first. WP option context_linker_log has every silent skip with the exact reason. This is the single biggest debug win — don't trust the API response, read the log.validate_suggestion() warningstemplates/settings-page.php, add the embedding hooks in class-embeddings.php (look for the existing post/term/product enqueue paths).class-suggestor.php system_prompt() AND to the validator's style whitelist in validate_suggestion().add_action('wp_ajax_...') in class-ajax.php init() AND the handler method. Use bracket-notation params in JS (NEVER JSON.stringify).These are the ones I'd defend even if a reviewer pushed back:
Context_Linker_Logger::log('warn', 'exact reason here') in every branch of every validator. When something breaks at 2 AM, the log tells you which branch fired.JSON.stringify a nested object for WP AJAX. The v1.0.7 bug cost hours.window.location.reload() + wp.data.dispatch('core/editor').resetEditorBlocks(). Non-negotiable for any plugin modifying post_content.MAX_BATCHES_PER_TICK = 20, ~500 items). Prevents a runaway queue from holding a request open forever. Items past the ceiling stay queued for the next cron tick.~/clawd/work/context-linker/~/clawd/work/context-linker.zip (58 KB)ai.tbs-staging.com (Alex owns the host, not me — I produce zip, he uploads)~/clawd/memory/2026-08-20.md, ~/clawd/memory/2026-08-21.mdalex@tbs-marketing.com email, Telegram ID in USER.md)package.sh → hands zip to Alex → Alex uploads