From 312083132ab7903d736d83029378e18f4eea7a8f Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:36:22 -0700 Subject: [PATCH 1/4] feat(capture): flag-gated #xray:capture navigation marker (27 K.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live preflight of the K.1 skill against the real connector found both driving paths dead: claude-in-chrome cannot navigate to chrome-extension:// URLs (it forces an https prefix) AND refuses to attach its debugger to extension pages — so extension-page JS (chrome.tabs.sendMessage, IDB reads, xray:llm:suggest) is unreachable, synthetic keys do not fire command shortcuts, and OS keystrokes into browsers are tier-blocked. Navigation of ordinary pages is the only verb an agent has. This is the K.4 slice the decision doc anticipated "where browser-driving is impractical". - content script: on init, if location.hash === "#xray:capture" AND the new captureAutomation flag is on, strip the marker (history.replaceState; the URL normalizer would drop it from stored URLs regardless), wait 1.5s for dynamic pages to settle, and trigger the SAME UI.openReader() the toolbar path uses. The outcome lands on document.documentElement.dataset.xrayCaptured (ok / error / flag-off) so the driver verifies from ordinary page context — no extension access needed. - captureAutomation joins FLAGS_DEFAULTS (default OFF, pin-tested) + an Options → Advanced toggle whose copy says exactly what it gates: the marker only; toolbar/shortcut/menu capture stay unconditional; captures pages, nothing more — no accepting, no publishing. - SKILL.md rewritten against verified reality: marker-driven capture with DOM-stamp verification, frontier enumeration from a user-provided workspace-backup export (extension IDB unreachable), PDFs handed to the user as a ?pdf= reader link, and suggest-pass scouting marked not-runnable by the connector (the reader Suggest button is the human path). Co-Authored-By: Claude Fable 5 --- .claude/skills/xray-capture/SKILL.md | 231 ++++++++++----------------- src/content/index.js | 38 +++++ src/options/index.js | 5 + src/options/options.html | 17 ++ src/shared/metadata/feature-flags.js | 7 + tests/llm-proposals.test.mjs | 6 + 6 files changed, 157 insertions(+), 147 deletions(-) diff --git a/.claude/skills/xray-capture/SKILL.md b/.claude/skills/xray-capture/SKILL.md index faf6e9e..01133c0 100644 --- a/.claude/skills/xray-capture/SKILL.md +++ b/.claude/skills/xray-capture/SKILL.md @@ -1,6 +1,6 @@ --- name: xray-capture -description: Capture articles into X-Ray by URL, enumerate an article's outbound-link frontier for corpus expansion, and run a scouting suggest pass. Use when the user asks to capture a URL into X-Ray, capture the links from an article, expand a case corpus, or scout what an article would yield. Requires the claude-in-chrome connector and the X-Ray extension loaded. +description: Capture articles into X-Ray by URL and enumerate an article's outbound-link frontier for corpus expansion, driving the loaded extension through the claude-in-chrome connector. Use when the user asks to capture a URL into X-Ray, capture the links from an article, or expand a case corpus. Requires the claude-in-chrome connector, the X-Ray extension loaded, and the Capture-automation flag ON. --- # X-Ray capture — drive the extension, expand the corpus @@ -12,165 +12,102 @@ never judge, and **nothing model-produced becomes durable X-Ray data through you** — proposals are accepted by the human in the reader, or not at all. +## Connector limits (verified 2026-07-16 — design around these, not against them) + +- The connector **cannot navigate to `chrome-extension://` URLs** (it + forces an `https://` prefix) and **cannot attach the debugger to + extension pages** ("Cannot attach debugger to chrome-extension:// + pages"). Everything that needed extension-page JS — IDB reads, + `chrome.tabs.sendMessage`, `xray:llm:suggest` — is unreachable. +- Synthetic keys do not fire extension command shortcuts, and OS-level + keystrokes into browsers are blocked by the computer-use tier. +- Therefore the ONLY verb you have against the extension is + **navigation of ordinary pages** — which is exactly what the + `#xray:capture` marker (Phase 27 K.4, flag-gated) exists for. + ## Hard rules (the project constitution — these bind every step) 1. **No auto-accept, ever.** Never write entities, claims, links, - facts, findings, or baselines into `chrome.storage.local` or the - extension's IndexedDB. The ONLY writes you may cause are the ones - the extension itself performs in response to its own capture flow - (`xray:capture` → reader auto-archive). If a suggest pass returns - proposals, you REPORT them; the human accepts in the reader. + facts, findings, or baselines anywhere. The ONLY writes you may + cause are the extension's own capture flow (the marker → reader + auto-archive). 2. **Never touch publish paths.** No relay messages, no signing. -3. **The extension's API key stays in its service worker.** Talk to - the LLM only via the extension's own `xray:llm:*` messages (which - gate on the user's flags + key). Never read `xray:llm:key`. -4. **Pace yourself.** Captures run sequentially — one tab, one URL at - a time, waiting for each load. Suggest passes are paid API calls: - state the count and get the user's go-ahead in chat before running - more than one. -5. **Capture only where directed.** The user names the seed URL(s) or +3. **Pace yourself.** Captures run sequentially — one URL at a time, + waiting for each load and verification before the next. +4. **Capture only where directed.** The user names the seed URL(s) or approves the frontier list before you fan out. Skip login-walled or paywalled pages you cannot render; report them as uncapturable rather than working around access controls. -6. **Forensic findings stay out of scope** for suggestion scouting - (maintainer decision 2026-07-16: not until the attribution fixes - are proven on real articles). +5. **Suggest-pass scouting is currently NOT runnable by this skill** + (it needs extension-context messaging the connector cannot reach). + The reader's own Suggest button is the human's path; recommend it + per-article instead of simulating it. ## Preflight (once per session) -1. Confirm the claude-in-chrome tools are available (load via - ToolSearch if deferred). -2. Find the X-Ray extension origin: call `tabs_context`. If no - `chrome-extension://` tab is open, ask the user to open the X-Ray - portal (extension icon → or `chrome://extensions` → X-Ray → - Details → open `src/portal/index.html`), then re-read - `tabs_context` and note the origin `chrome-extension://`. - Keep that portal tab open — it is your command surface. -3. Capability probe: run `javascript_tool` on the portal tab with - `typeof chrome.tabs.query` — expect `"function"`. If the tool - cannot execute JS on extension pages, STOP and tell the user this - skill needs that capability; do not improvise another write path. - -## Capture one URL (K.1) - -1. Open/navigate a NON-portal tab to the article URL. Wait for load - (screenshot or `get_page_text` sanity check — a real article body, - not a consent wall or error page). -2. From the **portal tab**, run `javascript_tool`: - - ```js - (async () => { - const tabs = await chrome.tabs.query({ url: '' }); - if (!tabs.length) return 'no-tab'; - const resp = await chrome.tabs.sendMessage(tabs[0].id, { type: 'xray:capture' }); - return resp && resp.ok ? 'capture-sent' : ('failed: ' + JSON.stringify(resp)); - })() - ``` - - (`url` patterns need a trailing `*` for query params; the message - lands in the content script — `src/content/index.js` — which opens - the reader, and the reader auto-archives on open.) -3. **PDFs:** skip steps 1–2 and instead open a tab at +1. Confirm the claude-in-chrome tools are loaded (ToolSearch if + deferred), then `tabs_context_mcp {createIfEmpty: true}`. +2. Ask the user to flip **Options → Advanced → Capture automation** ON + (the `#xray:capture` marker is default-off; it gates only the + marker). Remind them to turn it back off when the session ends. +3. Probe once: navigate a group tab to a harmless captureable page + with `#xray:capture` appended, wait ~3s, then `javascript_tool`: + `document.documentElement.dataset.xrayCaptured` — + - `"ok"` → capture fired; a reader tab opened (outside your group — + you won't see it; that's expected). + - `"flag-off"` → the flag isn't on; ask again. + - `undefined` → the extension may not be loaded, or the page blocked + the content script; try one reload, then report. + +## Capture one URL + +1. Navigate a group tab to `#xray:capture` (if the URL already + has a fragment, the marker replaces it — note that to the user). +2. Wait for load + ~3s (the marker waits 1.5s after init to let + dynamic pages settle, then opens the reader, which auto-archives). +3. Verify on the SAME tab (ordinary page — attachable): + `document.documentElement.dataset.xrayCaptured` → expect `"ok"`. + `"error"`/`undefined` → report the URL as failed (paywall? consent + wall? script-blocked?); retry at most once. +4. Report title (from the tab) and move to the next URL. +5. **Tab pileup is real:** every capture opens a reader tab the + connector cannot close (they're outside the group). Tell the user + at the end how many reader tabs they'll find and that each shows a + captured article ready for claim extraction. +6. **PDFs:** the reader's `?pdf=` path is an extension-page URL the + connector cannot open. Give the user the exact `chrome-extension:///src/reader/index.html?pdf=` - — the reader's tabless PDF path captures directly. -4. Verify the archive landed — from the portal tab: - - ```js - (async () => { - const db = await new Promise((res, rej) => { - const r = indexedDB.open('xray-archive'); - r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); - }); - const recs = await new Promise((res, rej) => { - const tx = db.transaction('articles').objectStore('articles').getAll(); - tx.onsuccess = () => res(tx.result); tx.onerror = () => rej(tx.error); - }); - db.close(); - const hit = recs.find((r) => r.url && r.url.includes('')); - return hit ? { ok: true, title: hit.article && hit.article.title, - links: (hit.article && hit.article.links || []).length } - : { ok: false, captured: recs.length }; - })() - ``` - - READ-ONLY: this is verification, never a write. If verification - fails, report it (paywall? consent wall? CSP?) and move on — do not - retry more than once. -5. Close the article tab if you opened it; report `title` + link - count to the user. - -## Enumerate the frontier (K.1) - -From the portal tab, read the seed article's outbound links and -subtract what's already captured: - -```js -(async () => { - const db = await new Promise((res, rej) => { - const r = indexedDB.open('xray-archive'); - r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); - }); - const recs = await new Promise((res, rej) => { - const tx = db.transaction('articles').objectStore('articles').getAll(); - tx.onsuccess = () => res(tx.result); tx.onerror = () => rej(tx.error); - }); - db.close(); - const seed = recs.find((r) => r.url && r.url.includes('')); - if (!seed) return 'seed-not-captured'; - const have = new Set(recs.map((r) => r.url)); - const links = ((seed.article && seed.article.links) || []) - .filter((l) => l && l.url && !l.internal) - .filter((l) => !have.has(l.url)); - return { frontier: links.map((l) => ({ url: l.url, text: l.text, count: l.count })), - truncated: !!(seed.article && seed.article.links_truncated) }; -})() -``` - -Present the frontier as a numbered list (url + anchor text + cite -count). **Wait for the user to pick** which to capture (or confirm -"all"), then loop the capture procedure over the picks, one at a -time, reporting progress. Notes: `internal` is same-host-approximate; -`links_truncated` means the capture capped at 100 links — say so. - -## Scouting suggest pass (K.2 — optional, costs API tokens) - -Purpose: tell the researcher what a captured article would yield -BEFORE they invest reader time. It does NOT feed the reader's review -panel — accepting still happens there (and the reader's own Suggest -run is a second paid pass; say so when recommending it). - -1. The pass scopes itself to the USER'S stored suggestion kinds - (`xray:llm:suggest_kinds` — the worker reads it; the request cannot - override it, by design). Read it first (portal tab, - `chrome.storage.local.get`) so you can tell the user what the pass - will cover. If `findings` or `baselines` are enabled there, note - that per rule 6 you will not elaborate those proposals in your - summary — review them in the reader. -2. Get the article text from the archive record - (`record.article.markdown` — fall back to `textContent` if absent). -3. From the portal tab: - - ```js - (async () => new Promise((res) => chrome.runtime.sendMessage({ - type: 'xray:llm:suggest', - request: { articleText: , articleUrl: , articleTitle: } - }, res)))() - ``` - - Gates (llmAssist flag + key + at-least-one-kind) are enforced - worker-side; if the response is `{ok:false}`, relay its error - verbatim (it names the Options toggle to flip). -4. Report a scouting summary in chat: counts per kind + the two or - three most load-bearing claims verbatim (skip findings/baselines - per rule 6). Recommend which articles deserve a reader pass. Do - not store anything. + link to open by hand instead. + +## Enumerate the frontier + +Extension IDB is unreachable, so the frontier comes from a **workspace +backup export** the user hands you: + +1. Ask the user: Options → Advanced → Workspace → download backup + (source bytes not needed), and give you the file path (usually + `~/Downloads/…json`). +2. Parse it in node (it is one JSON object): + `databases['xray-archive'].stores.articles` (explore the exact + nesting — `format` and `exportedAt` sit at the top level) → each + record has `url`, `articleHash`, and `article.links` (shape + `{url, text, count, internal}`, capped at 100 with + `article.links_truncated`). +3. Frontier = every non-`internal` link URL across the seed records, + minus every `url` already in the archive, deduped, with citation + counts and which seeds cite it. Disclose `links_truncated` seeds. +4. Present the frontier as a numbered pick list sorted by citation + count. **Wait for the user to pick** (or confirm "all"), then loop + the capture procedure sequentially over the picks. +5. The export is a snapshot — captures made after it won't be in it. + Re-request an export if you need a fresh frontier; don't guess. ## Failure modes | Symptom | Meaning | Do | |---|---|---| -| `javascript_tool` refuses extension pages | capability missing | Stop; tell the user (preflight step 3) | -| `no-tab` from capture | URL pattern mismatch | Add `*` for query strings; re-query | -| `sendMessage` throws "no receiving end" | content script not injected (chrome:// page, PDF viewer, race) | PDFs → the `?pdf=` reader path; else reload the tab once | -| Verification finds no record | paywall/consent wall/CSP ate the capture | Report as uncapturable; do not bypass | -| Suggest returns `ok:false` | flag off or no key | Relay the error; the user flips it in Options | +| `dataset.xrayCaptured === "flag-off"` | Capture automation off | Ask the user to enable it in Options → Advanced | +| `dataset.xrayCaptured` undefined after reload | content script absent (blocked page, chrome://, PDF viewer) | Report uncapturable; PDFs → hand the user the `?pdf=` reader link | +| `"error"` stamp | capture threw | Report; check the page loaded real content | +| Navigate mangles the URL | you passed a `chrome-extension://` URL | Don't — ordinary pages only (see Connector limits) | +| Frontier stale | export predates recent captures | Ask for a fresh export | diff --git a/src/content/index.js b/src/content/index.js index deedc71..90767a5 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -13,6 +13,7 @@ import { Signer } from '../shared/signer.js'; import { NIP07Client } from './nip07-client.js'; import { UI } from './ui.js'; import { installBufferListener, configureInterceptor } from '../shared/api-hook-buffer.js'; +import { loadFlags, isEnabled } from '../shared/metadata/feature-flags.js'; async function init() { // Initialize storage (migrates from any legacy GM storage if present). @@ -130,6 +131,43 @@ async function init() { } Utils.log('Initialization complete'); + + // Phase 27 K.4 — the automation capture marker. A driving agent + // (the xray-capture skill) can neither reach extension pages nor + // fire the command shortcut through the browser connector, so + // NAVIGATION is its only verb: opening `<url>#xray:capture` + // triggers the same capture the toolbar button would. Flag-gated, + // default OFF (Options → Advanced → Capture automation). The + // marker is navigation plumbing, not page identity — it is + // stripped before capture (the URL normalizer would drop it from + // stored URLs regardless), and the outcome is stamped on the DOM + // (`data-xray-captured`) so the driver can verify from ordinary + // page context. Captures the page, nothing more. + if (window.location.hash === '#xray:capture') { + try { + await loadFlags(); + if (isEnabled('captureAutomation')) { + history.replaceState(null, '', + window.location.pathname + window.location.search); + // Give late-loading pages the settle time a human + // clicking the toolbar would naturally allow. + setTimeout(() => { + try { + UI.openReader(); + document.documentElement.dataset.xrayCaptured = 'ok'; + } catch (err) { + document.documentElement.dataset.xrayCaptured = 'error'; + Utils.error('Automation capture failed:', err); + } + }, 1500); + } else { + document.documentElement.dataset.xrayCaptured = 'flag-off'; + Utils.log('#xray:capture marker present but captureAutomation is off'); + } + } catch (err) { + Utils.error('Automation capture marker failed:', err); + } + } } /** diff --git a/src/options/index.js b/src/options/index.js index e91f1f0..00b9edb 100644 --- a/src/options/index.js +++ b/src/options/index.js @@ -757,6 +757,7 @@ async function loadAdvanced() { // Case synthesis (Phase 20.4) — requires llmAssist + the key on top. document.getElementById('pref-case-synthesis').checked = isEnabled('caseSynthesis'); + document.getElementById('pref-capture-automation').checked = isEnabled('captureAutomation'); // LLM assist (Phase 14.5). The flag lives in feature-flags; the key // + model live under their own chrome.storage.local keys. We never @@ -871,6 +872,10 @@ async function saveAdvanced() { const synthOn = document.getElementById('pref-case-synthesis').checked; await setOverride('caseSynthesis', synthOn ? true : null); + // Capture automation (Phase 27 K.4). + const captureAutoOn = document.getElementById('pref-capture-automation').checked; + await setOverride('captureAutomation', captureAutoOn ? true : null); + // LLM assist: flag + model preference always; the key only when the // user typed a new one (blank leaves the saved key untouched). const llmOn = document.getElementById('pref-llm-assist').checked; diff --git a/src/options/options.html b/src/options/options.html index 795ec32..ba99ba3 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -476,6 +476,23 @@ <h3 class="xr-opt__subhead">Moral lens</h3> browser session. </p> + <hr /> + <h3 class="xr-opt__subhead">Capture automation</h3> + <label class="xr-opt__field xr-opt__field--inline"> + <input type="checkbox" id="pref-capture-automation" /> + <span>Allow the <code>#xray:capture</code> URL marker to trigger a capture</span> + </label> + <p class="xr-opt__hint" style="margin-top:-8px"> + Off by default. When on, opening any page whose address ends in + <code>#xray:capture</code> captures it and opens the reader — + exactly as the toolbar button would. This exists so a driving + agent (the <code>xray-capture</code> skill) can queue captures by + navigation alone; the marker is stripped before capture and never + reaches the stored URL. It captures pages, nothing more: no + accepting, no publishing. Leave off unless an agent session is + driving. + </p> + <hr /> <h3 class="xr-opt__subhead">Case synthesis</h3> <label class="xr-opt__field xr-opt__field--inline"> diff --git a/src/shared/metadata/feature-flags.js b/src/shared/metadata/feature-flags.js index a47449a..334248a 100644 --- a/src/shared/metadata/feature-flags.js +++ b/src/shared/metadata/feature-flags.js @@ -113,6 +113,13 @@ export const FLAGS_DEFAULTS = Object.freeze({ // ordinary 30040/30055 through the normal publish paths. caseSynthesis: false, + // Phase 27 K.4: the `#xray:capture` URL marker — a driving agent's + // capture trigger (the connector can neither reach extension pages + // nor fire the command shortcut, so navigation is the only verb it + // has). Gates ONLY the marker; the toolbar/shortcut/menu capture + // paths are unconditional as ever. Captures pages, nothing more. + captureAutomation: false, + // Phase 25 (docs/NETWORK_CLIENT_DESIGN.md §8): gates the Network // SURFACE — the standalone follows-feed page, its context-menu item, // and the options/sidepanel links. Reading relays is not a diff --git a/tests/llm-proposals.test.mjs b/tests/llm-proposals.test.mjs index cacbd3e..21f83cc 100644 --- a/tests/llm-proposals.test.mjs +++ b/tests/llm-proposals.test.mjs @@ -392,6 +392,12 @@ test('buildAssessmentInput: an unlocatable label quote saves the label WITHOUT a assert.equal(input.labels[1].label, 'unsupported'); }); +test('captureAutomation defaults OFF — the #xray:capture marker is opt-in (27 K.4)', async () => { + const { FLAGS_DEFAULTS } = await import('../src/shared/metadata/feature-flags.js'); + assert.equal(FLAGS_DEFAULTS.captureAutomation, false, + 'the navigation-marker capture trigger must be explicitly enabled'); +}); + test('findings prompt carries the attribution rules — speaker vs reporter (27 F.2)', () => { const sys = buildSystemPrompt({ tasks: ['findings'] }); assert.match(sys, /the party who\s+PERFORMED the move/); From 04bab2103905427c23963d7dcfd8e25585b781f3 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:42:49 -0700 Subject: [PATCH 2/4] fix(phase-27): apply the S/F/K adversarial-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 confirmed findings from the 21-agent review of the S.1..K.2 diff (one — the skill IDB-creation hazard — was already erased by the K.4 rewrite). Applied on the stack tip so every PR in the chain carries them at merge: MAJORS - llm-review blockedReason now gates finding/baseline rows on subject- entity acceptance (the F.3 identity join was order-dependent: accepting a finding before its subject entity silently landed it label-keyed — fragmented and unpublishable). Label-only subjects stay acceptable. - EDIT_FIELDS.baseline gains the subject_ref field (correcting a baseline subject via the label was cosmetic while the stale ref kept the identity keyspace); both subject field labels now state the precedence rule (ref wins; blank ref = label). - Re-running synthesis carries record.triage forward — content-derived keys make re-proposed pairs keep their status; without this every corpus-v2 re-run resurrected every dismissed proposal. Plus the pure partitionProposals seam extracted from the DOM render + tests (partition, unknown-status-stays-open, flipped-endpoint stability). MINORS - case-view local-chip pass: endpoints canonicalized ONCE into a ref-indexed map (was rows x all-links x canon re-parses — ~40x on a loaded workspace) and capped at 6 chips/row matching the published cap, with a "+N more local" disclosure. - Reduce prompt: art keys are for READING the index; anywhere the tool asks for article_hash, supply the FULL hash from `articles` — never an art key. - Finding modal: switching between two baselined subjects no longer carries a stale cross-subject baselineRef; editing a finding whose subject is no longer tagged falls to the custom slot with its own label instead of silently re-pointing at the first choice. - Role validation message interpolates the live ROLES (was a hardcoded pre-F.6 list). - Baseline delete clears baseline_ref on referencing findings (with the blast radius stated in the confirm); the dangling-ref render branch is now tested (generic no-weight tooltip, nothing fabricated). - 30062 parser role tolerance pinned (journalist parses; a foreign "prosecutor" passes through on read — the NIP_DRAFT MUST, tested). - DIGEST_CLAIM_CAP x art-key interaction pinned (the articles map derives from the SAME capped slice). - Stale entitiesById comment corrected (S.2 made a builder read it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- src/portal/case-view.js | 55 ++++++++++++++++++++++---------- src/portal/synthesis-block.js | 9 +++++- src/portal/synthesis-review.js | 31 ++++++++++++------ src/reader/index.js | 13 ++++++-- src/reader/llm-review.js | 19 +++++++++-- src/shared/case-dossier.js | 4 ++- src/shared/corpus-prompts.js | 4 +++ src/shared/forensic-modal.js | 17 ++++++++-- src/shared/llm-proposals.js | 4 +-- tests/case-synthesis.test.mjs | 20 ++++++++++++ tests/forensic-builders.test.mjs | 13 ++++++++ tests/forensic-section.test.mjs | 7 ++++ tests/synthesis-review.test.mjs | 53 ++++++++++++++++++++++++++++++ 13 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 tests/synthesis-review.test.mjs diff --git a/src/portal/case-view.js b/src/portal/case-view.js index 0ebc9ce..1f9ad73 100644 --- a/src/portal/case-view.js +++ b/src/portal/case-view.js @@ -431,30 +431,51 @@ export function renderCaseView(host, params) { const snap = side === 'source' ? link.source_snapshot : link.target_snapshot; return (snap && snap.text) || 'another claim'; }; + // Canonical endpoints are row-invariant: index the global + // registry ONCE (O(links)), then each row is a map lookup — + // not rows × links × canon() re-parses. + const byEndpoint = new Map(); // canonical ref → [{link, src, tgt}] + for (const link of Object.values(allLinks)) { + if (link.publishedAt) continue; // already a wire chip + const entry = { link, src: canon(link.source_claim_id), tgt: canon(link.target_claim_id) }; + for (const ref of entry.src === entry.tgt ? [entry.src] : [entry.src, entry.tgt]) { + if (!byEndpoint.has(ref)) byEndpoint.set(ref, []); + byEndpoint.get(ref).push(entry); + } + } + const MAX_LOCAL_CHIPS = 6; // the published-chip cap, matched for (const [coord, row] of rowByCoord) { const canonical = canon(coord); - for (const link of Object.values(allLinks)) { - if (link.publishedAt) continue; // already a wire chip - const src = canon(link.source_claim_id); - const tgt = canon(link.target_claim_id); - if (src !== canonical && tgt !== canonical) continue; + const entries = byEndpoint.get(canonical) || []; + let shown = 0; + let over = 0; + let relRow = null; + for (const { link, src, tgt } of entries) { + const isContradicts = link.relationship === 'contradicts'; + if (!isContradicts && !RELATED_RELATIONSHIPS.has(link.relationship)) continue; + if (shown >= MAX_LOCAL_CHIPS) { over++; continue; } + if (!relRow) { + relRow = row.querySelector('.xr-case__related'); + if (!relRow) { relRow = el('div', 'xr-case__related'); row.appendChild(relRow); } + } const dir = src === canonical ? 'out' : 'in'; - const otherRef = dir === 'out' ? tgt : src; - const otherSide = dir === 'out' ? 'target' : 'source'; - let relRow = row.querySelector('.xr-case__related'); - if (!relRow) { relRow = el('div', 'xr-case__related'); row.appendChild(relRow); } - if (link.relationship === 'contradicts') { + if (isContradicts) { const b = el('span', 'xr-badge xr-badge--warn', '⚠ contradicted (local)'); b.title = 'A local contradiction link — not yet published'; relRow.appendChild(b); - continue; + } else { + const otherRef = dir === 'out' ? tgt : src; + const otherSide = dir === 'out' ? 'target' : 'source'; + const phrase = (RELATED_PHRASE[link.relationship] || {})[dir] || link.relationship; + const b = el('span', 'xr-badge xr-badge--muted', + `${dir === 'out' ? '→' : '←'} ${phrase} (local): ${truncate(nameOf(otherRef, link, otherSide), 60)}`); + b.title = 'A local link — not yet published to relays'; + relRow.appendChild(b); } - if (!RELATED_RELATIONSHIPS.has(link.relationship)) continue; - const phrase = (RELATED_PHRASE[link.relationship] || {})[dir] || link.relationship; - const b = el('span', 'xr-badge xr-badge--muted', - `${dir === 'out' ? '→' : '←'} ${phrase} (local): ${truncate(nameOf(otherRef, link, otherSide), 60)}`); - b.title = 'A local link — not yet published to relays'; - relRow.appendChild(b); + shown++; + } + if (over > 0 && relRow) { + relRow.appendChild(el('span', 'xr-inspector__mono', `… +${over} more local`)); } } })().catch((err) => Utils.error('Local link chips failed', err)); diff --git a/src/portal/synthesis-block.js b/src/portal/synthesis-block.js index a526bf2..f6c9b96 100644 --- a/src/portal/synthesis-block.js +++ b/src/portal/synthesis-block.js @@ -405,13 +405,20 @@ export function renderSynthesisBlock(host, { data, dossier, callbacks = {} }) { for (const m of members) indexByMember[m.article_hash] = createGroundingIndex(m.text); const grounded = groundCaseBrief(reduce.briefInput, indexByMember); + // Triage survives a RE-RUN too (27 S.3 review fix): keys are + // content-derived, so a re-proposed pair keeps its status + // and keys for proposals the new brief no longer makes are + // inert. Without this, every corpus-v2 re-run resurrected + // every dismissed proposal. + const prior = await getCaseBrief(caseId).catch(() => null); const record = { caseId, brief: grounded.brief, grounding: { checked: grounded.checked, dropped: grounded.dropped }, inputHash: liveHash, model: reduce.model, promptVersion: CORPUS_PROMPT_VERSION, members: members.length, analyzed: extracts.length, failed: failures.length, - usage: reduce.usage || null + usage: reduce.usage || null, + triage: (prior && prior.triage) || {} }; try { await saveCaseBrief(record); } catch (err) { Utils.error('saveCaseBrief failed', err); } diff --git a/src/portal/synthesis-review.js b/src/portal/synthesis-review.js index 2948d41..b0219c9 100644 --- a/src/portal/synthesis-review.js +++ b/src/portal/synthesis-review.js @@ -30,6 +30,26 @@ function describe(p, claimsById) { return p.kind; } +/** + * Pure triage partition (27 S.3) — the seam the render projects. + * `triage` maps proposalKey → 'accepted' | 'dismissed'; anything else + * (missing key, unknown status) is OPEN — an unrecognized status must + * never hide a proposal. + */ +export function partitionProposals(acceptable, triage = {}) { + const open = []; + const accepted = []; + const dismissed = []; + for (const p of acceptable || []) { + const key = proposalKey(p); + const status = triage[key]; + if (status === 'accepted') accepted.push(p); + else if (status === 'dismissed') dismissed.push({ p, key }); + else open.push({ p, key }); + } + return { open, accepted, dismissed }; +} + async function accept(p, { model, memberByHash }) { const suggested_by = `llm:${model || 'unknown'}`; if (p.kind === 'relationship') { @@ -74,16 +94,7 @@ export function renderProposals(host, { acceptable, rejected, claimsById, member } }; - const open = []; - const accepted = []; - const dismissed = []; - for (const p of acceptable || []) { - const key = proposalKey(p); - const status = triage[key]; - if (status === 'accepted') accepted.push(p); - else if (status === 'dismissed') dismissed.push({ p, key }); - else open.push({ p, key }); - } + const { open, accepted, dismissed } = partitionProposals(acceptable, triage); host.appendChild(el('h4', 'xr-case__heading', `Proposals — review and accept (${open.length})`)); diff --git a/src/reader/index.js b/src/reader/index.js index a21db17..0f3f222 100644 --- a/src/reader/index.js +++ b/src/reader/index.js @@ -1673,8 +1673,17 @@ async function refreshFindingsBar() { host.querySelectorAll('.xr-findings__baseline').forEach((row) => { const delBtn = row.querySelector('[data-action="baseline-delete"]'); if (delBtn) delBtn.addEventListener('click', async () => { - if (!confirm('Remove this baseline?')) return; - await ForensicBaseline.delete(row.dataset.id); + if (!confirm('Remove this baseline? Findings that marked a deviation from it keep their evidence but lose the baseline link.')) return; + const baselineId = row.dataset.id; + await ForensicBaseline.delete(baselineId); + // Clear dangling deviation links (baseline_ref is editable + // context, not identity, so this is an update not a rekey). + const findings = await ForensicModel.getAll(); + for (const f of Object.values(findings)) { + if (f.baseline_ref === baselineId) { + await ForensicModel.update(f.id, { baseline_ref: null }).catch(() => {}); + } + } toast('Baseline removed', 'success', 1500); await refreshFindingsBar(); }); diff --git a/src/reader/llm-review.js b/src/reader/llm-review.js index f824901..8cd2eac 100644 --- a/src/reader/llm-review.js +++ b/src/reader/llm-review.js @@ -94,8 +94,8 @@ const EDIT_FIELDS = { // 27 F.3 — the misattribution backstop: the subject is // correctable at review time (it was the ONE field you // couldn't fix, before or after accept). - { key: 'subject_ref', label: 'Subject entity ref (E1, E2… from the entities above; blank = use label)', type: 'text' }, - { key: 'subject_label', label: 'Subject label (who PERFORMS the move — not who reports it)', type: 'text' }, + { key: 'subject_ref', label: 'Subject entity ref (E1, E2…; blank = use label — the ref wins when both are set)', type: 'text' }, + { key: 'subject_label', label: 'Subject label (who PERFORMS the move — used only when the ref is blank)', type: 'text' }, { key: 'role', label: 'Role', type: 'select', options: ROLES }, { key: 'maneuver', label: 'Maneuver', type: 'text' }, { key: 'basis', label: 'Basis', type: 'select', options: BASIS_VALUES }, @@ -104,7 +104,11 @@ const EDIT_FIELDS = { { key: 'counter_note', label: 'Counter-read (required)', type: 'textarea' } ], baseline: [ - { key: 'subject_label', label: 'Subject', type: 'text' }, + // Review fix (27 F.3): the ref is editable/clearable here too — + // without it, correcting a baseline's subject via the label was + // cosmetic while the identity join kept the old keyspace. + { key: 'subject_ref', label: 'Subject entity ref (E1, E2…; blank = use label — the ref wins when both are set)', type: 'text' }, + { key: 'subject_label', label: 'Subject label (used only when the ref is blank)', type: 'text' }, { key: 'note', label: 'Note', type: 'textarea' } ] }; @@ -517,6 +521,15 @@ export async function openLlmReview(opts) { if (!entityIdByRef[p.subject_ref]) return 'Accept its subject entity first.'; if (p.value_entity_ref && !entityIdByRef[p.value_entity_ref]) return 'Accept the value entity first.'; } + // 27 F.3 review fix: the identity join is order-dependent — + // a finding/baseline accepted BEFORE its subject entity + // would silently land label-keyed (fragmented, silently + // unpublishable). Same gate facts always had. Label-only + // subjects (no ref) stay acceptable as before. + if ((row.kind === 'finding' || row.kind === 'baseline') + && p.subject_ref && !entityIdByRef[p.subject_ref]) { + return 'Accept its subject entity first.'; + } return ''; } diff --git a/src/shared/case-dossier.js b/src/shared/case-dossier.js index be1ea4c..b40e056 100644 --- a/src/shared/case-dossier.js +++ b/src/shared/case-dossier.js @@ -184,7 +184,9 @@ export async function collectCaseDossierData(caseEntityId, options = {}) { // Full entity registry snapshot (Phase 20.3) — the case graph // resolves names for entities TAGGED on member articles that // never entered an orbit claim (so aren't in orbit.entities). - // Builders ignore it; it only rides for graph consumers. + // Consumed by graph consumers AND (27 S.2) by buildCaseDossier + // itself, which reads the case entity's authored scope question + // from it. entitiesById: allEntities, orbit: { entity_ids: entityIds, diff --git a/src/shared/corpus-prompts.js b/src/shared/corpus-prompts.js index 43a7772..b6e1cd9 100644 --- a/src/shared/corpus-prompts.js +++ b/src/shared/corpus-prompts.js @@ -257,6 +257,10 @@ export function buildReduceSystemPrompt({ caseName = '', scopeQuestion = '' } = ' listed under `contradictions` in the digest.', '- Propose `is_key` for existing claims the whole case turns on, and `claim` for', ' load-bearing assertions in the extracts that have no claim yet.', + '- `art` keys are shorthand for READING the claims index only: the digest\'s `articles`', + ' object maps each key to its full 64-hex hash. Anywhere the tool asks for an', + ' `article_hash` (holders, evidence_refs, load_bearing, claim proposals), supply the', + ' FULL hash from `articles` or the extract headers — never an `art` key.', 'HARD RULES (docs/PHILOSOPHY.md):', '- NEVER output a verdict, score, probability, or "who is right". Disagreement is DATA —', ' present it, do not resolve it.', diff --git a/src/shared/forensic-modal.js b/src/shared/forensic-modal.js index 820b258..313cc9c 100644 --- a/src/shared/forensic-modal.js +++ b/src/shared/forensic-modal.js @@ -81,8 +81,15 @@ export async function openFindingModal({ } = {}) { ensureStyles(); + // An existing finding whose subject is no longer among the tagged + // entities must fall to the CUSTOM slot carrying its own label — + // otherwise the select silently lands on the first choice and an + // edit-save would offer (and persist context against) the wrong + // subject's baselines. + const existingKey = existing ? subjectKeyOf(existing.subject_ref) : null; const initialSubjectKey = existing - ? (subjectKeyOf(existing.subject_ref) || '__custom__') + ? ((existingKey && subjectChoices.some((c) => c.key === existingKey)) + ? existingKey : '__custom__') : (subjectChoices[0] ? subjectChoices[0].key : '__custom__'); const state = { @@ -170,7 +177,13 @@ export async function openFindingModal({ if (!field || !sel) return; const ref = resolveSubjectRef(state, subjectChoices); const bases = await ForensicBaseline.getForSubject(ref).catch(() => []); - if (!bases.length) { field.hidden = true; state.baselineRef = existing ? state.baselineRef : null; return; } + // A baselineRef belonging to a DIFFERENT subject must not + // survive a subject switch — the rebuilt select would show + // "(none)" while the stale id silently persisted on save. + if (state.baselineRef && !bases.some((b) => b.id === state.baselineRef)) { + state.baselineRef = null; + } + if (!bases.length) { field.hidden = true; return; } sel.innerHTML = `<option value="">(none)</option>${bases.map((b) => `<option value="${escapeHtml(b.id)}" ${b.id === state.baselineRef ? 'selected' : ''}>${escapeHtml((b.note || '').slice(0, 80))}</option>`).join('')}`; field.hidden = false; diff --git a/src/shared/llm-proposals.js b/src/shared/llm-proposals.js index 23e7154..d5d2afc 100644 --- a/src/shared/llm-proposals.js +++ b/src/shared/llm-proposals.js @@ -40,7 +40,7 @@ import { CLAIM_RELATIONSHIPS, REVISION_RELATIONSHIPS } from './assessment-taxonomy.js'; import { - isValidManeuver, isValidRole, isValidBasis, BASIS_VALUES + isValidManeuver, isValidRole, isValidBasis, BASIS_VALUES, ROLES } from './forensic-taxonomy.js'; // Dependency order: a kind is only ever accepted after the kinds it can @@ -266,7 +266,7 @@ export function validateProposal(prop, ctx = {}) { } case 'finding': { if (!subjectLabelOf(prop, ctx)) return fail('Finding needs a subject'); - if (!isValidRole(prop.role)) return fail(`Role must be one of: apologist, critic, institution, witness, survivor, other`); + if (!isValidRole(prop.role)) return fail(`Role must be one of: ${ROLES.join(', ')}`); if (!isValidManeuver(prop.maneuver)) return fail(`Invalid maneuver: ${prop.maneuver}`); const basis = prop.basis || 'structural-inference'; if (!isValidBasis(basis)) return fail(`Basis must be one of: ${BASIS_VALUES.join(', ')}`); diff --git a/tests/case-synthesis.test.mjs b/tests/case-synthesis.test.mjs index 33d4d0d..c4c9081 100644 --- a/tests/case-synthesis.test.mjs +++ b/tests/case-synthesis.test.mjs @@ -121,6 +121,26 @@ test('case-synthesis: digest claims carry short per-article keys so cross-articl 'the 64-hex hash no longer rides every claim entry'); }); +test('case-synthesis: DIGEST_CLAIM_CAP bounds the index AND the art-key map together (27 S.1)', () => { + const dossier = { coverage: {}, shape_of_knowledge: {}, knots: {}, orbit: {} }; + const claims = []; + for (let i = 0; i < CS.DIGEST_CLAIM_CAP + 10; i++) { + claims.push({ id: `c${i}`, text: `Claim ${i}.`, article_hash: `${String(i % 3)}`.repeat(64) }); + } + // The capped tail cites a hash NO capped claim carries — its art + // key must not leak into the articles map. + claims[CS.DIGEST_CLAIM_CAP + 5].article_hash = 'f'.repeat(64); + const digest = JSON.parse(CS.digestDossier(dossier, { claims })); + assert.equal(digest.claim_count, CS.DIGEST_CLAIM_CAP); + assert.equal(digest.claims.length, CS.DIGEST_CLAIM_CAP); + assert.ok(!digest.claims.some((c) => c.id === `c${CS.DIGEST_CLAIM_CAP}`), 'claims beyond the cap are absent'); + const mapped = new Set(Object.values(digest.articles)); + assert.ok(!mapped.has('f'.repeat(64)), 'articles map derives from the SAME capped slice'); + for (const c of digest.claims) { + assert.ok(c.art === null || digest.articles[c.art], `art key ${c.art} resolves`); + } +}); + test('case-synthesis: proposalKey is stable and direction-insensitive for relationships (27 S.3)', () => { // The triage record persists under these keys — a key change would // silently resurrect every dismissed proposal. diff --git a/tests/forensic-builders.test.mjs b/tests/forensic-builders.test.mjs index 8cf7da4..97f4ed1 100644 --- a/tests/forensic-builders.test.mjs +++ b/tests/forensic-builders.test.mjs @@ -123,6 +123,19 @@ test('30062: build → parse round-trip', async () => { assert.equal(parseBehavioralFindingEvent({ kind: 30054 }), null, 'wrong kind → null'); }); +test('30062 parse: role values beyond the original six are tolerated (NIP_DRAFT MUST, 27 F.6)', async () => { + const { event } = await buildBehavioralFindingEvent(baseArgs({ role: 'journalist' })); + event.pubkey = 'f'.repeat(64); + event.id = 'e'.repeat(64); + assert.equal(parseBehavioralFindingEvent(event).role, 'journalist'); + // A FOREIGN event with a role outside our taxonomy still parses — + // consumers MUST tolerate unknown role values. + const foreign = JSON.parse(JSON.stringify(event)); + for (const t of foreign.tags) if (t[0] === 'role') t[1] = 'prosecutor'; + assert.equal(parseBehavioralFindingEvent(foreign).role, 'prosecutor', + 'unknown role passes through on read; only the BUILD side validates'); +}); + test('1985 mirror: labels the SUBJECT pubkey under xray/forensic', async () => { const { event, dTag } = buildForensicFindingMirrorEvent({ subjectPubkey: PK, maneuver: 'darvo/attack', sourceUrl: 'https://example.com/x' diff --git a/tests/forensic-section.test.mjs b/tests/forensic-section.test.mjs index 173ba55..4d4339e 100644 --- a/tests/forensic-section.test.mjs +++ b/tests/forensic-section.test.mjs @@ -80,6 +80,13 @@ test('bar: baselines render as plain rows — visible at last, never a weight (2 assert.match(withChip, /Baseline: Even, fact-anchored register\./); const withoutRef = renderFindingsBar([finding()], [baseline]); assert.doesNotMatch(withoutRef, /deviates from baseline/); + // Dangling ref (baseline deleted elsewhere / foreign data): the + // chip still renders with the generic no-weight tooltip — never a + // crash, never a fabricated note. + const dangling = renderFindingsBar([finding({ baseline_ref: 'baseline_gone00000000' })], [baseline]); + assert.match(dangling, /deviates from baseline/); + assert.match(dangling, /context — not a weight/, 'the generic disclaimer tooltip, not a fabricated note'); + assert.doesNotMatch(dangling, /Baseline: Even/); for (const html of [empty, withChip]) { assert.doesNotMatch(html, /score|rating|weight|confidence/i); } diff --git a/tests/synthesis-review.test.mjs b/tests/synthesis-review.test.mjs new file mode 100644 index 0000000..d0f88cd --- /dev/null +++ b/tests/synthesis-review.test.mjs @@ -0,0 +1,53 @@ +// Proposal-triage partition tests — 27 S.3 review fix. The DOM render +// projects the pure `partitionProposals` seam 1:1; these pin the +// contract the persistence rides on: statuses key by proposalKey +// (content-derived, endpoint-order-insensitive), and anything without +// a RECOGNIZED status is OPEN — an unknown status must never hide a +// proposal. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +globalThis.chrome = globalThis.chrome || { + storage: { local: { get(_k, cb) { cb({}); }, set(_o, cb) { cb && cb(); }, remove(_k, cb) { cb && cb(); } } } +}; + +const { partitionProposals } = await import('../src/portal/synthesis-review.js'); +const { proposalKey } = await import('../src/shared/case-synthesis.js'); + +const rel = (s, t) => ({ kind: 'relationship', source_claim_id: s, target_claim_id: t, relationship: 'supports' }); +const key = (p) => proposalKey(p); + +test('synthesis-review: partition routes by triage status; missing/unknown statuses stay open', () => { + const a = rel('c1', 'c2'); + const b = rel('c3', 'c4'); + const c = { kind: 'is_key', claim_id: 'c5' }; + const d = rel('c6', 'c7'); + const triage = { + [key(a)]: 'accepted', + [key(b)]: 'dismissed', + [key(c)]: 'starred' // unknown status → must stay open + }; + const { open, accepted, dismissed } = partitionProposals([a, b, c, d], triage); + assert.deepEqual(accepted, [a]); + assert.deepEqual(dismissed.map((x) => x.p), [b]); + assert.deepEqual(open.map((x) => x.p), [c, d], 'unknown status and no status both stay open'); + assert.equal(open[1].key, key(d), 'open rows carry the key the triage write will use'); +}); + +test('synthesis-review: a re-proposed relationship with flipped endpoints keeps its triage status', () => { + // The reduce may emit the same logical pair in either direction on + // a re-run; the endpoint-order-insensitive key means a dismissal + // survives the flip. + const triage = { [key(rel('c1', 'c2'))]: 'dismissed' }; + const { open, dismissed } = partitionProposals([rel('c2', 'c1')], triage); + assert.equal(open.length, 0); + assert.equal(dismissed.length, 1); +}); + +test('synthesis-review: empty inputs partition to empty — the zero-proposals message is reachable', () => { + const { open, accepted, dismissed } = partitionProposals([], {}); + assert.deepEqual([open, accepted, dismissed], [[], [], []]); + const noTriage = partitionProposals([rel('c1', 'c2')]); + assert.equal(noTriage.open.length, 1, 'triage map optional'); +}); From 726019e609f81f297addebed5b1292c94b1536e1 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:17:58 -0700 Subject: [PATCH 3/4] fix(capture): the marker-strip must never abort the capture (27 K.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in the first live agent-driven capture run, on archive.is — several COVID-corpus frontier links are archive.is snapshots of paywalled reporting, and the marker fired exactly once there, then never again: SecurityError: Failed to execute replaceState on History: A history state object with URL https://d8w6fmknl1l6hl.archive.is/7gYpy cannot be created in a document with origin https://archive.is archive.is serves snapshots under a randomized subdomain and sets a <base href> to it. replaceState resolves a RELATIVE argument against the document base URI — not its origin — so the rewrite targeted another host and threw. The strip sat before UI.openReader() inside the same try, so a cosmetic address-bar tidy killed the capture it preceded. It appeared to work on the first hit only because the base tag had not been applied yet — which is also why that capture archived the interstitial instead of the article. - Rebuild the replacement URL ABSOLUTE from location.origin (a relative one is another site\s <base href> to redirect), and give the strip its OWN try/catch: cosmetics can never abort load-bearing work behind them. A failed strip now logs and continues; the URL normalizer drops fragments from stored URLs regardless. - A hash-only navigation (<url> → <url>#xray:capture) is a same-document navigation, so init() never re-ran and the marker silently no-oped; a hashchange listener now covers it. The marker branch is extracted to maybeAutomationCapture() and stamps "error" on any throw instead of staying silent. - SKILL.md: SPA settle times (~10s), the stamp-is-not-content rule (check title/length — an "ok" stamp on an interstitial is the real failure mode), re-capture via full navigation, console-first debugging, and an honest per-URL reporting section. - JOURNAL entry: the generalizable lesson is that a try-block wrapping a nice-to-have around a must-have inherits the nice-to-have\s failure modes. No unit test would have caught either bug — both need a real page with a cross-origin base tag. Found by running the tool for real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .claude/skills/xray-capture/SKILL.md | 33 ++++++++++-- docs/JOURNAL.md | 40 ++++++++++++++ src/content/index.js | 81 ++++++++++++++++++++-------- 3 files changed, 128 insertions(+), 26 deletions(-) diff --git a/.claude/skills/xray-capture/SKILL.md b/.claude/skills/xray-capture/SKILL.md index 01133c0..183ac7a 100644 --- a/.claude/skills/xray-capture/SKILL.md +++ b/.claude/skills/xray-capture/SKILL.md @@ -63,13 +63,29 @@ or not at all. 1. Navigate a group tab to `<URL>#xray:capture` (if the URL already has a fragment, the marker replaces it — note that to the user). -2. Wait for load + ~3s (the marker waits 1.5s after init to let +2. Wait for load + ~5s (the marker waits 1.5s after init to let dynamic pages settle, then opens the reader, which auto-archives). + **SPAs (Medium, some news sites) need ~10s** — a capture that fires + before the body renders archives an empty shell. 3. Verify on the SAME tab (ordinary page — attachable): `document.documentElement.dataset.xrayCaptured` → expect `"ok"`. `"error"`/`undefined` → report the URL as failed (paywall? consent wall? script-blocked?); retry at most once. -4. Report title (from the tab) and move to the next URL. +4. **A stamp of `"ok"` means the capture FIRED, not that the content + was good.** Check `document.title` in the same probe: a title like + `"archive.is"`, `"Just a moment…"`, `"Medium"`, or a bare hostname + means an interstitial/loading state was live when the capture ran — + wait for the real title and re-capture (see the re-capture note + below). Batch the title into the verification expression: + `({stamp: document.documentElement.dataset.xrayCaptured, title: document.title, len: document.body.innerText.length})` + — `len` under ~2000 on a supposed article is another tell. +5. **Re-capturing:** navigate somewhere else first (`https://example.com`) + and back. Going from `<url>` to `<url>#xray:capture` used to be a + silent no-op (same-document navigation → no re-init); a `hashchange` + listener now covers it, but a full navigation is still the reliable + path, and re-capture is safe — the archive keys by URL and keeps the + prior version. +6. Report title (from the tab) and move to the next URL. 5. **Tab pileup is real:** every capture opens a reader tab the connector cannot close (they're outside the group). Tell the user at the end how many reader tabs they'll find and that each shows a @@ -108,6 +124,17 @@ backup export** the user hands you: |---|---|---| | `dataset.xrayCaptured === "flag-off"` | Capture automation off | Ask the user to enable it in Options → Advanced | | `dataset.xrayCaptured` undefined after reload | content script absent (blocked page, chrome://, PDF viewer) | Report uncapturable; PDFs → hand the user the `?pdf=` reader link | -| `"error"` stamp | capture threw | Report; check the page loaded real content | +| `"error"` stamp | the marker branch threw | Read the console (`pattern: "X-Ray"`) — it logs the reason verbatim | +| Stamp `"ok"` but the title is `archive.is` / `Just a moment…` / `Medium` | captured an interstitial, not the article | Wait for the real title, then re-capture via a full navigation | +| Stamp never appears on one specific host | the content script may be throwing before the marker runs | Console first, guess never — this is how the archive.is `<base href>` bug was found (JOURNAL 2026-07-17) | | Navigate mangles the URL | you passed a `chrome-extension://` URL | Don't — ordinary pages only (see Connector limits) | | Frontier stale | export predates recent captures | Ask for a fresh export | + +## Reporting back + +Give the user a per-URL table (title + outcome), not just a count. +State plainly: how many reader tabs are now open (one per capture, all +outside the group — you cannot close them), which URLs failed and why, +and which captured content you are UNSURE about. An honest "this one +may have caught a loading page" is worth more than a green checkmark +they later discover was empty. diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 486103c..56468bc 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -19,6 +19,46 @@ or files, and the "so-what" for future readers. --- +## 2026-07-17 — A cosmetic replaceState aborted the capture it preceded (27 K.4) + +Tags: `bug`, `external`. + +Found in the first live agent-driven capture run, on `archive.is` +(several COVID-corpus frontier links are archive.is snapshots of +paywalled reporting). The `#xray:capture` marker fired once, then +never again on that host: + +``` +SecurityError: Failed to execute 'replaceState' on 'History': +A history state object with URL 'https://d8w6fmknl1l6hl.archive.is/7gYpy' +cannot be created in a document with origin 'https://archive.is' +``` + +archive.is serves snapshot content under a **randomized subdomain** and +sets a `<base href>` to it. `history.replaceState(null, '', pathname + +search)` resolves its RELATIVE argument against the document's **base +URI**, not its origin — so the rewrite targeted another host and threw. +The marker-strip sat before `UI.openReader()` inside the same `try`, so +a cosmetic address-bar tidy killed the capture. It "worked" on the very +first hit only because the base tag hadn't been applied yet — which is +also why that capture archived the interstitial rather than the article. + +Two fixes, both worth keeping in mind for any content-script URL work: +build the replacement **absolute from `location.origin`** (never +relative — `<base href>` is another site's to control), and give +best-effort cosmetics **their own try/catch** so they can never abort +the load-bearing work behind them. Also fixed alongside: a hash-only +navigation (`<url>` → `<url>#xray:capture`) is a same-document +navigation, so `init()` never re-runs and the marker silently no-oped; +a `hashchange` listener now covers it. + +No unit test would have caught either — both need a real page with a +cross-origin base tag. The lesson generalizes past this slice: a +try-block that wraps a nice-to-have around a must-have inherits the +nice-to-have's failure modes. + +--- + ## 2026-07-16 — The scope question never reached the LLM; proposals were never asked for (27 S.1/S.2) Tags: `bug`, `design`. diff --git a/src/content/index.js b/src/content/index.js index 90767a5..c6faa85 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -143,30 +143,65 @@ async function init() { // stored URLs regardless), and the outcome is stamped on the DOM // (`data-xray-captured`) so the driver can verify from ordinary // page context. Captures the page, nothing more. - if (window.location.hash === '#xray:capture') { - try { - await loadFlags(); - if (isEnabled('captureAutomation')) { - history.replaceState(null, '', - window.location.pathname + window.location.search); - // Give late-loading pages the settle time a human - // clicking the toolbar would naturally allow. - setTimeout(() => { - try { - UI.openReader(); - document.documentElement.dataset.xrayCaptured = 'ok'; - } catch (err) { - document.documentElement.dataset.xrayCaptured = 'error'; - Utils.error('Automation capture failed:', err); - } - }, 1500); - } else { - document.documentElement.dataset.xrayCaptured = 'flag-off'; - Utils.log('#xray:capture marker present but captureAutomation is off'); - } - } catch (err) { - Utils.error('Automation capture marker failed:', err); + await maybeAutomationCapture(); + // A hash-only navigation (same page, marker appended) does NOT + // re-run init — the driver's capture would silently no-op. Listen + // so the marker works from an already-loaded page too. + window.addEventListener('hashchange', () => { + maybeAutomationCapture().catch((err) => + Utils.error('Automation capture marker failed:', err)); + }); +} + +/** + * The `#xray:capture` automation marker (Phase 27 K.4). Flag-gated, + * default OFF. Triggers the same capture the toolbar button does, and + * stamps the outcome on the DOM so a driving agent can verify from + * ordinary page context. + */ +async function maybeAutomationCapture() { + if (window.location.hash !== '#xray:capture') return; + try { + await loadFlags(); + if (!isEnabled('captureAutomation')) { + document.documentElement.dataset.xrayCaptured = 'flag-off'; + Utils.log('#xray:capture marker present but captureAutomation is off'); + return; } + stripCaptureMarker(); + // Give late-loading pages the settle time a human clicking the + // toolbar would naturally allow. + setTimeout(() => { + try { + UI.openReader(); + document.documentElement.dataset.xrayCaptured = 'ok'; + } catch (err) { + document.documentElement.dataset.xrayCaptured = 'error'; + Utils.error('Automation capture failed:', err); + } + }, 1500); + } catch (err) { + document.documentElement.dataset.xrayCaptured = 'error'; + Utils.error('Automation capture marker failed:', err); + } +} + +/** + * Drop the marker from the address bar. Cosmetic ONLY — the URL + * normalizer strips fragments from stored URLs regardless — so it must + * never block the capture it precedes. The URL is rebuilt ABSOLUTE + * from the origin: a relative argument resolves against the document's + * base URI, and a page whose `<base href>` points at another host + * (archive.is serves content under a randomized subdomain) makes + * replaceState throw SecurityError — which previously aborted the + * whole capture. + */ +function stripCaptureMarker() { + try { + history.replaceState(null, '', + window.location.origin + window.location.pathname + window.location.search); + } catch (err) { + Utils.log('Capture marker left in the address bar (harmless):', err && err.message); } } From 1b6c1cf1096536c7542ee5dd31c0e4c055bab421 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:28:41 -0700 Subject: [PATCH 4/4] feat(llm): offer Fable 5 + Sonnet 5; disclose model refusals honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model picker (llm-prompts.js LLM_MODELS) gains Claude Fable 5 and Claude Sonnet 5. The table is the single source of truth — the Options dropdown renders it verbatim and every pass resolves through resolveModel() — so the additions are the table rows plus the pin test asserting every offered id resolves to itself (a picker entry that silently fell back to the default would be a lie). Payload compatibility verified rather than assumed: the passes send only model/max_tokens/system/tools/tool_choice/messages — no temperature, top_p, top_k, or thinking/budget_tokens, which are exactly the params Fable 5 and Sonnet 5 reject with a 400. Both work unchanged. Fable 5 needs no thinking config (thinking is always on there; an explicit disabled would 400). Refusal handling (the real gap): Fable 5 runs safety classifiers that can DECLINE a request — HTTP 200, stop_reason "refusal", empty or partial content. Only the lens pass handled that; the other six fell through to extractToolInput() → null → "the model did not return a structured extract", blaming malformed output for a guardrail and sending the user hunting a bug that does not exist. A shared refusalResult() helper now generalizes the lens pass's own §6 rule ("a guardrail firing is its own state") across suggest, audit, audit module, corpus map, corpus reduce, and hypothesis edges — carrying the safety category when present, stating it is not a key/network/X-Ray problem, and naming the workaround (switch model in Options). stop_details is treated as optional, per the API contract. This matters concretely for the COVID-origins corpus: it is bio-adjacent, and Fable 5's classifiers target research biology — a refusal there is plausible and must read as a guardrail, not a bug. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- src/shared/llm-client.js | 35 ++++++++++++++++++++++++++++++ src/shared/llm-prompts.js | 9 +++++++- tests/llm-proposals.test.mjs | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/shared/llm-client.js b/src/shared/llm-client.js index e8ec150..0c3c7ff 100644 --- a/src/shared/llm-client.js +++ b/src/shared/llm-client.js @@ -201,6 +201,35 @@ async function postMessages(payload, apiKey, { signal } = {}) { return { ok: true, data }; } +/** + * A model-side safety guardrail declining the request is its OWN state, + * never the generic "malformed output" error (the lens pass's §6 rule, + * generalized here). Claude Fable 5 runs classifiers that can decline — + * bio and cyber topics especially — and returns HTTP 200 with + * `stop_reason: 'refusal'` and an empty/partial content array. Without + * this check every caller falls through to extractToolInput() → null → + * "the model did not return a structured extract", which blames the + * wrong thing and sends the user hunting a bug that isn't there. + * + * Returns an `{ ok: false, refused: true, ... }` result to return as-is, + * or null when the response was not a refusal. + * + * @param {object} data the parsed Messages response + * @param {string} what what was being produced, for the message + */ +export function refusalResult(data, what) { + if (!data || data.stop_reason !== 'refusal') return null; + const category = (data.stop_details && data.stop_details.category) || null; + return { + ok: false, refused: true, code: 'model-refusal', category, + error: `The model declined to produce ${what}` + + (category ? ` (safety category: ${category})` : '') + + '. This is a model-side guardrail — not a key, network, or X-Ray problem. ' + + 'Some models decline topics others allow; switching model in ' + + 'Options → Advanced → LLM assist is the usual workaround.' + }; +} + /** * Pull a forced tool's `input` out of a Messages response, by tool name. * Returns the input object, or null if no matching tool_use was found. @@ -280,6 +309,7 @@ export async function runSuggestionPass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'capture suggestions for this article'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The model hit its output limit before finishing. Try a shorter article or fewer tasks.' }; } @@ -391,6 +421,7 @@ export async function runAuditPass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'this audit'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The model hit its output limit before finishing the audit. Try a shorter article.' }; } @@ -478,6 +509,7 @@ export async function runAuditModulePass(req = {}) { if (!res.ok) return { ...res, module: name }; const data = res.data; + { const r = refusalResult(data, `the ${name} audit module`); if (r) return { ...r, module: name }; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, module: name, error: `The ${name} module hit its output limit before finishing.` }; } @@ -562,6 +594,7 @@ export async function runCorpusMapPass(req = {}) { if (!res.ok) return { ...res, member_id: req.member_id }; const data = res.data; + { const r = refusalResult(data, 'an extract for this article'); if (r) return { ...r, member_id: req.member_id }; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, member_id: req.member_id, error: 'The map call hit its output limit before finishing.' }; } @@ -602,6 +635,7 @@ export async function runCorpusReducePass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'the corpus brief'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The synthesis hit its output limit before finishing.' }; } @@ -648,6 +682,7 @@ export async function runHypothesisEdgePass(req = {}) { if (!res.ok) return res; const data = res.data; + { const r = refusalResult(data, 'hypothesis edge proposals'); if (r) return r; } if (data && data.stop_reason === 'max_tokens') { return { ok: false, error: 'The edge-suggestion call hit its output limit before finishing.' }; } diff --git a/src/shared/llm-prompts.js b/src/shared/llm-prompts.js index 173f3ff..4318e6a 100644 --- a/src/shared/llm-prompts.js +++ b/src/shared/llm-prompts.js @@ -30,9 +30,16 @@ import { ENTITY_TYPES } from './entity-model.js'; // latest capable Claude; the Options picker renders these. // ------------------------------------------------------------------ +// Ordered most-capable-first; the Options picker renders this verbatim. +// Adding a model is this line + nothing else — every caller resolves +// through resolveModel(), and the corpus/lens/audit passes all read the +// user's stored choice. Cost note (per MTok in/out, 2026-07): Fable 5 +// $10/$50 · Opus 4.8 $5/$25 · Sonnet 5 $3/$15 · Haiku 4.5 $1/$5. export const LLM_MODELS = Object.freeze([ - { id: 'claude-opus-4-8', label: 'Claude Opus 4.8 (most capable)' }, + { id: 'claude-fable-5', label: 'Claude Fable 5 (most capable — highest cost)' }, + { id: 'claude-opus-4-8', label: 'Claude Opus 4.8 (most capable Opus)' }, { id: 'claude-opus-4-7', label: 'Claude Opus 4.7' }, + { id: 'claude-sonnet-5', label: 'Claude Sonnet 5 (near-Opus quality, Sonnet cost)' }, { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (balanced)' }, { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 (fastest / cheapest)' } ]); diff --git a/tests/llm-proposals.test.mjs b/tests/llm-proposals.test.mjs index 21f83cc..9a7cbec 100644 --- a/tests/llm-proposals.test.mjs +++ b/tests/llm-proposals.test.mjs @@ -506,6 +506,47 @@ test('resolveModel defaults unknown ids to the latest capable model', () => { assert.equal(resolveModel('claude-sonnet-4-6'), 'claude-sonnet-4-6'); }); +test('the model picker offers Fable 5 and Sonnet 5, and every id resolves', async () => { + const { LLM_MODELS } = await import('../src/shared/llm-prompts.js'); + const ids = LLM_MODELS.map((m) => m.id); + assert.deepEqual(ids, [ + 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', + 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-haiku-4-5' + ]); + // Every offered id must survive resolveModel — a picker entry that + // silently falls back to the default is a lie to the user. + for (const id of ids) assert.equal(resolveModel(id), id); + for (const m of LLM_MODELS) assert.ok(m.label && m.label.length > 3, m.id); + assert.ok(ids.includes(DEFAULT_LLM_MODEL), 'the default is offered'); +}); + +test('a model refusal is its own disclosed state, never a malformed-output error', async () => { + const { refusalResult } = await import('../src/shared/llm-client.js'); + // Fable 5 declines with HTTP 200 + stop_reason refusal and an empty + // content array — the shape that would otherwise fall through to + // "the model did not return a structured extract". + const refused = refusalResult( + { stop_reason: 'refusal', stop_details: { type: 'refusal', category: 'bio' }, content: [] }, + 'the corpus brief'); + assert.equal(refused.ok, false); + assert.equal(refused.refused, true); + assert.equal(refused.code, 'model-refusal'); + assert.equal(refused.category, 'bio'); + assert.match(refused.error, /declined to produce the corpus brief/); + assert.match(refused.error, /safety category: bio/); + assert.match(refused.error, /not a key, network, or X-Ray problem/); + assert.match(refused.error, /switching model/, 'names the actual workaround'); + // stop_details is optional — never assume it is present (it is null + // for every non-refusal stop reason). + const bare = refusalResult({ stop_reason: 'refusal' }, 'suggestions'); + assert.equal(bare.category, null); + assert.doesNotMatch(bare.error, /safety category/); + // Everything else passes through untouched. + assert.equal(refusalResult({ stop_reason: 'end_turn' }, 'x'), null); + assert.equal(refusalResult({ stop_reason: 'max_tokens' }, 'x'), null); + assert.equal(refusalResult(null, 'x'), null); +}); + test('llmAssist flag defaults OFF', async () => { const { FLAGS_DEFAULTS } = await import('../src/shared/metadata/feature-flags.js'); assert.equal(FLAGS_DEFAULTS.llmAssist, false);