@expo/code-review-cli 0.12.2 → 0.12.4
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/README.md +32 -3
- package/build/config/schema.js +5 -2
- package/build/core/prompts.js +41 -26
- package/build/core/research.js +165 -464
- package/build/core/review.js +53 -5
- package/build/core/schema.js +25 -1
- package/build/core/verify.js +44 -8
- package/build/research-mcp/audit.js +42 -3
- package/build/research-mcp/cli.js +2 -1
- package/build/research-mcp/direct-fetch.js +73 -13
- package/build/research-mcp/expo-algolia.js +4 -4
- package/build/research-mcp/html.js +4 -1
- package/build/research-mcp/providers.js +38 -0
- package/build/research-mcp/query-sanitizer.js +16 -3
- package/build/research-mcp/remote-search.js +20 -6
- package/build/research-mcp/search-index.js +4 -0
- package/build/research-mcp/server.js +65 -16
- package/build/research-mcp/types.js +1 -0
- package/package.json +1 -1
- package/research/sources.json +12 -0
- package/templates/config.jsonc +7 -7
- package/templates/shared.md +19 -6
package/build/core/review.js
CHANGED
|
@@ -18,7 +18,7 @@ import { errorMessage, sleep } from "./util.js";
|
|
|
18
18
|
import { reviewSetupRefNotes } from "./config-refs.js";
|
|
19
19
|
import { verifyFindings } from "./verify.js";
|
|
20
20
|
import { applyInlineIgnores } from "./suppress.js";
|
|
21
|
-
import { createResearchMcpRuntime, formatResearchProgress, groundResearchSources, mergeResearchSources, researchProvenanceFromAudit, renderResearchMarkdown, } from "./research.js";
|
|
21
|
+
import { boundResearchDecisions, createResearchMcpRuntime, formatResearchProgress, formatResearchUsefulness, groundResearchDecisions, groundResearchSources, mergeResearchSources, researchProvenanceFromAudit, renderResearchMarkdown, renderResearchUsefulnessMarkdown, summarizeResearchUsefulness, } from "./research.js";
|
|
22
22
|
/**
|
|
23
23
|
* Filter changed files down to an explicit include set (exact-path membership, not
|
|
24
24
|
* globs — scope assignment already happened in resolveScopes). With no include set,
|
|
@@ -307,6 +307,9 @@ export async function runReview(source, options) {
|
|
|
307
307
|
// reviewers produced before the failure — partial findings are exactly what's
|
|
308
308
|
// needed to debug a run that died mid-way.
|
|
309
309
|
const agentFindings = {};
|
|
310
|
+
// Reviewer-declared, conclusion-only records of cases where documentation changed
|
|
311
|
+
// a concrete candidate decision. They remain inert until exact-source grounding.
|
|
312
|
+
const agentResearchDecisions = {};
|
|
310
313
|
// Bounded, conclusion-only diagnostics for machine consumers of the hidden
|
|
311
314
|
// comment state. These are deliberately separate from findings: they never reach
|
|
312
315
|
// the coordinator, verification, policy, or decision paths.
|
|
@@ -521,6 +524,9 @@ export async function runReview(source, options) {
|
|
|
521
524
|
trackTokens(task.bucket, tokens);
|
|
522
525
|
trackModel(task.bucket, taskModel(task), model);
|
|
523
526
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
527
|
+
if (value.researchDecisions) {
|
|
528
|
+
(agentResearchDecisions[task.bucket] ??= []).push(...value.researchDecisions);
|
|
529
|
+
}
|
|
524
530
|
if (value.trace) {
|
|
525
531
|
mergeTraceNotes(agentTrace, task.bucket, value.trace);
|
|
526
532
|
}
|
|
@@ -644,6 +650,20 @@ export async function runReview(source, options) {
|
|
|
644
650
|
sourcesByFp.set(fp, mergeResearchSources(sourcesByFp.get(fp), finding.sources));
|
|
645
651
|
}
|
|
646
652
|
}
|
|
653
|
+
if (researchRecord) {
|
|
654
|
+
const groundedDecisions = Object.entries(agentResearchDecisions).flatMap(([agent, records]) => groundResearchDecisions(records, researchEvidence, agent));
|
|
655
|
+
const { decisions, omitted } = boundResearchDecisions(groundedDecisions);
|
|
656
|
+
if (decisions.length > 0)
|
|
657
|
+
researchRecord = { ...researchRecord, decisions };
|
|
658
|
+
if (omitted > 0) {
|
|
659
|
+
const warning = `${omitted} grounded research decision(s) omitted by output bounds`;
|
|
660
|
+
researchRecord = {
|
|
661
|
+
...researchRecord,
|
|
662
|
+
warnings: [...researchRecord.warnings, warning],
|
|
663
|
+
};
|
|
664
|
+
progress(` research: ${warning}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
647
667
|
// A substituted model means the review did not run on the model this repo
|
|
648
668
|
// configured — the findings may be from a weaker (or free-tier) model entirely.
|
|
649
669
|
// Never silent: it goes to the log, the coverage notes, and the run log.
|
|
@@ -734,25 +754,43 @@ export async function runReview(source, options) {
|
|
|
734
754
|
const findingCountBeforeChecks = output.findings.length;
|
|
735
755
|
const decisionBeforeChecks = output.decision;
|
|
736
756
|
let verifierDropped = [];
|
|
757
|
+
// Citations the verifier stripped from kept findings, persisted to the run
|
|
758
|
+
// log so the removal reason stays auditable — mirrors verifierDropped.
|
|
759
|
+
let citationStrips = [];
|
|
737
760
|
// Stripped requalifications (finding + reason), persisted to the run log so the
|
|
738
761
|
// stack-aware decision trail is auditable after the fact — mirrors verifierDropped.
|
|
739
762
|
const requalificationStrips = [];
|
|
740
763
|
if (output.findings.length > 0) {
|
|
741
764
|
progress("Verifying findings…");
|
|
742
|
-
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
|
|
765
|
+
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress, researchEvidence);
|
|
743
766
|
agentCosts["verifier"] = verification.cost;
|
|
744
767
|
trackTokens("verifier", verification.tokens);
|
|
745
768
|
// Mirrors buildOpencodeConfig, which gives the verifier the first agent's model.
|
|
746
769
|
trackModel("verifier", config.agents[0]?.model ?? config.coordinator.model, verification.model);
|
|
747
770
|
verifierDropped = verification.dropped;
|
|
748
|
-
|
|
749
|
-
|
|
771
|
+
citationStrips = verification.citationStripped;
|
|
772
|
+
if (verification.dropped.length > 0 || verification.citationStripped.length > 0) {
|
|
773
|
+
if (verification.dropped.length > 0) {
|
|
774
|
+
progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
|
|
775
|
+
}
|
|
750
776
|
output = {
|
|
751
777
|
...output,
|
|
752
778
|
findings: verification.kept,
|
|
753
|
-
decision
|
|
779
|
+
// Re-derive the decision ONLY when findings were dropped. A citation
|
|
780
|
+
// strip keeps every finding, and decisionAfterVerification would
|
|
781
|
+
// downgrade a criticals-free request_changes to approve_with_comments —
|
|
782
|
+
// a blocking review must not stop blocking because a link was removed.
|
|
783
|
+
decision: verification.dropped.length > 0
|
|
784
|
+
? decisionAfterVerification(output.decision, verification.kept)
|
|
785
|
+
: output.decision,
|
|
754
786
|
};
|
|
755
787
|
}
|
|
788
|
+
// The verifier judged these citations unsupportive. Remove the
|
|
789
|
+
// fingerprint-carried copies too, or the post-coordination merge below
|
|
790
|
+
// would silently restore the stripped sources.
|
|
791
|
+
for (const stripped of verification.citationStripped) {
|
|
792
|
+
sourcesByFp.delete(fingerprintFinding(stripped.finding));
|
|
793
|
+
}
|
|
756
794
|
}
|
|
757
795
|
// Stack-aware requalification grounding (deterministic, zero LLM): strip any
|
|
758
796
|
// `requalifiedBy` the coordinator wrote that is forged, hallucinated, or touches a
|
|
@@ -903,6 +941,15 @@ export async function runReview(source, options) {
|
|
|
903
941
|
progress(`Author-reply adjudication failed (${errorMessage(error)}); continuing without it.`);
|
|
904
942
|
}
|
|
905
943
|
}
|
|
944
|
+
// Measure utility only after verification, suppression, citation carry-through,
|
|
945
|
+
// and feedback adjudication have produced the final finding set. Counts use
|
|
946
|
+
// unique audited URLs, not passages, so duplicate search hits cannot inflate them.
|
|
947
|
+
if (researchRecord) {
|
|
948
|
+
const usefulness = summarizeResearchUsefulness(researchRecord, output.findings);
|
|
949
|
+
researchRecord = { ...researchRecord, usefulness };
|
|
950
|
+
progress(formatResearchUsefulness(usefulness));
|
|
951
|
+
await appendStepSummary(renderResearchUsefulnessMarkdown(researchRecord));
|
|
952
|
+
}
|
|
906
953
|
// Surface provider throttling as a fact about the run: passes already waited or
|
|
907
954
|
// backed off, but the operator should still SEE that it happened (a run that
|
|
908
955
|
// was rate-limited is slower and may carry partial passes — that's the cause).
|
|
@@ -947,6 +994,7 @@ export async function runReview(source, options) {
|
|
|
947
994
|
coverageNotes,
|
|
948
995
|
verifierDropped,
|
|
949
996
|
requalificationStrips,
|
|
997
|
+
citationStrips,
|
|
950
998
|
...(rlTotal > 0
|
|
951
999
|
? {
|
|
952
1000
|
rateLimitEvents: rlTotal,
|
package/build/core/schema.js
CHANGED
|
@@ -17,6 +17,17 @@ export const FindingSourceSchema = z.object({
|
|
|
17
17
|
.max(2_000)
|
|
18
18
|
.refine((value) => new URL(value).protocol === "https:", "source URL must use HTTPS"),
|
|
19
19
|
});
|
|
20
|
+
export const RESEARCH_DECISION_OUTCOMES = ["supported-finding", "dismissed-candidate"];
|
|
21
|
+
/**
|
|
22
|
+
* A reviewer-declared decision that documentation materially changed. Sources are
|
|
23
|
+
* later grounded against the MCP audit exactly like finding citations; an ungrounded
|
|
24
|
+
* record is discarded and can never inflate usefulness metrics.
|
|
25
|
+
*/
|
|
26
|
+
export const ResearchDecisionSchema = z.object({
|
|
27
|
+
outcome: z.enum(RESEARCH_DECISION_OUTCOMES),
|
|
28
|
+
summary: z.string().min(1).max(240),
|
|
29
|
+
sources: z.array(FindingSourceSchema).min(1).max(5),
|
|
30
|
+
});
|
|
20
31
|
export const FindingSchema = z.object({
|
|
21
32
|
severity: z.enum(SEVERITIES),
|
|
22
33
|
category: z.enum(CATEGORIES),
|
|
@@ -29,7 +40,7 @@ export const FindingSchema = z.object({
|
|
|
29
40
|
.array(FindingSourceSchema)
|
|
30
41
|
.max(5)
|
|
31
42
|
.optional()
|
|
32
|
-
.describe("Exact documentation sources used to support this finding; copy title and URL from
|
|
43
|
+
.describe("Exact documentation sources used to support this finding; copy the returned title and canonical URL from this review's MCP results and omit when unused"),
|
|
33
44
|
/**
|
|
34
45
|
* Verbatim snippet of the flagged code, copied from the file. Used to
|
|
35
46
|
* quote-ground the finding: if this text isn't actually present in the file,
|
|
@@ -79,6 +90,12 @@ export function isOverallRiskHandoff(finding) {
|
|
|
79
90
|
export const VerdictSchema = z.object({
|
|
80
91
|
verified: z.boolean(),
|
|
81
92
|
reason: z.string().default(""),
|
|
93
|
+
/**
|
|
94
|
+
* Only requested when the finding cites research: whether the cited passages
|
|
95
|
+
* genuinely support the finding's external-behavior claim. `false` strips the
|
|
96
|
+
* citation while the finding itself stands or falls on `verified`.
|
|
97
|
+
*/
|
|
98
|
+
citationSupported: z.boolean().optional(),
|
|
82
99
|
});
|
|
83
100
|
/**
|
|
84
101
|
* A stack verifier's verdict on whether a later stacked PR's patch actually
|
|
@@ -125,6 +142,7 @@ export const ReviewerTraceNotesSchema = z.object({
|
|
|
125
142
|
const ReviewerModelOutputSchema = z.object({
|
|
126
143
|
findings: z.array(ModelFindingSchema).default([]),
|
|
127
144
|
trace: ReviewerTraceNotesSchema.optional(),
|
|
145
|
+
researchDecisions: z.array(ResearchDecisionSchema).max(8).optional(),
|
|
128
146
|
});
|
|
129
147
|
/**
|
|
130
148
|
* Local trust boundary for reviewer output. Findings stay strict, while diagnostics
|
|
@@ -135,12 +153,18 @@ export const ReviewerOutputSchema = z
|
|
|
135
153
|
.object({
|
|
136
154
|
findings: z.array(ModelFindingSchema).default([]),
|
|
137
155
|
trace: z.unknown().optional(),
|
|
156
|
+
researchDecisions: z.unknown().optional(),
|
|
138
157
|
})
|
|
139
158
|
.transform((output) => {
|
|
140
159
|
const trace = ReviewerTraceNotesSchema.safeParse(output.trace);
|
|
160
|
+
const researchDecisions = z
|
|
161
|
+
.array(ResearchDecisionSchema)
|
|
162
|
+
.max(8)
|
|
163
|
+
.safeParse(output.researchDecisions);
|
|
141
164
|
return {
|
|
142
165
|
findings: output.findings,
|
|
143
166
|
...(trace.success ? { trace: trace.data } : {}),
|
|
167
|
+
...(researchDecisions.success ? { researchDecisions: researchDecisions.data } : {}),
|
|
144
168
|
};
|
|
145
169
|
});
|
|
146
170
|
export const ReviewTraceSchema = z.object({
|
package/build/core/verify.js
CHANGED
|
@@ -12,6 +12,17 @@ import { errorMessage, normalizeCode } from "./util.js";
|
|
|
12
12
|
const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
13
13
|
// Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
|
|
14
14
|
const MIN_EVIDENCE_LEN = 12;
|
|
15
|
+
/** The audited passages behind a finding's grounded citations, bounded per source. */
|
|
16
|
+
function citedSourcesFor(finding, evidence) {
|
|
17
|
+
if (!finding.sources?.length || evidence.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
const byUrl = new Map(evidence.map((item) => [item.url, item]));
|
|
20
|
+
const cited = finding.sources.flatMap((source) => {
|
|
21
|
+
const match = byUrl.get(source.url);
|
|
22
|
+
return match ? [{ title: match.title, url: match.url, passage: match.passage }] : [];
|
|
23
|
+
});
|
|
24
|
+
return cited.length > 0 ? cited : undefined;
|
|
25
|
+
}
|
|
15
26
|
// @ref LLP 0005#evidence-grounding-escalate-never-hard-drop [implements] — exact-substring is a good positive but poor negative signal (33a970a revert)
|
|
16
27
|
/**
|
|
17
28
|
* Break `evidence` into normalized, substantive fragments for fuzzy matching:
|
|
@@ -95,31 +106,43 @@ async function evidencePresence(finding, cwd) {
|
|
|
95
106
|
* real findings whose natural evidence (a structural/absence bug, a cross-line
|
|
96
107
|
* quote, a slightly-wrong location) wasn't a verbatim substring.
|
|
97
108
|
*/
|
|
98
|
-
export async function verifyFindings(handle, findings, cwd, onProgress
|
|
109
|
+
export async function verifyFindings(handle, findings, cwd, onProgress,
|
|
110
|
+
/** This run's audited research evidence, for findings that cite documentation. */
|
|
111
|
+
researchEvidence = []) {
|
|
99
112
|
const dropped = [];
|
|
113
|
+
const citationStripped = [];
|
|
114
|
+
// Kept findings the verifier rewrote (currently only citation removal).
|
|
115
|
+
const replacements = new Map();
|
|
100
116
|
let cost = 0;
|
|
101
117
|
let model;
|
|
102
118
|
const tokens = {};
|
|
103
119
|
// Phase 1 — deterministic quote-grounding for every finding.
|
|
104
120
|
const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
|
|
105
|
-
// Decide which findings need an LLM check vs. can be kept directly.
|
|
121
|
+
// Decide which findings need an LLM check vs. can be kept directly. A finding
|
|
122
|
+
// that cites documentation always gets an LLM check: the repo alone cannot
|
|
123
|
+
// confirm an external-behavior claim, and the verifier must judge whether the
|
|
124
|
+
// cited passages support it rather than fall back to model memory.
|
|
106
125
|
const verdicts = new Map();
|
|
107
126
|
const toVerify = [];
|
|
108
127
|
for (const { finding, presence } of checked) {
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
const citedSources = citedSourcesFor(finding, researchEvidence);
|
|
129
|
+
if (presence === "absent" || finding.severity === "critical" || citedSources) {
|
|
130
|
+
toVerify.push({ finding, presence, ...(citedSources ? { citedSources } : {}) });
|
|
111
131
|
}
|
|
112
132
|
else {
|
|
113
133
|
verdicts.set(finding, "keep"); // grounded (or uncheckable) non-critical
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
// Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
|
|
117
|
-
await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
|
|
137
|
+
await Promise.all(toVerify.map(async ({ finding, presence, citedSources }, index) => {
|
|
118
138
|
try {
|
|
119
139
|
const { value, cost: verifyCost, tokens: verifyTokens, model: verifyModel, } = await promptAndParse(handle, {
|
|
120
140
|
agent: VERIFIER_AGENT,
|
|
121
141
|
system: buildVerifierSystem(),
|
|
122
|
-
text: buildVerifierTask(finding, {
|
|
142
|
+
text: buildVerifierTask(finding, {
|
|
143
|
+
evidenceUngrounded: presence === "absent",
|
|
144
|
+
...(citedSources ? { citedSources } : {}),
|
|
145
|
+
}),
|
|
123
146
|
title: `verify-${index}`,
|
|
124
147
|
maxWaitMs: VERIFY_TIMEOUT_MS,
|
|
125
148
|
finalizeOnTimeout: true,
|
|
@@ -130,6 +153,17 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
130
153
|
model = verifyModel ?? model;
|
|
131
154
|
if (value.verified) {
|
|
132
155
|
verdicts.set(finding, "keep");
|
|
156
|
+
// An explicit false strips the citation; the finding itself stands.
|
|
157
|
+
// Absent or true leaves the grounded sources untouched (fail open).
|
|
158
|
+
if (citedSources && value.citationSupported === false) {
|
|
159
|
+
const { sources: _sources, ...withoutSources } = finding;
|
|
160
|
+
replacements.set(finding, withoutSources);
|
|
161
|
+
citationStripped.push({
|
|
162
|
+
finding,
|
|
163
|
+
reason: "the verifier judged the cited passages unsupportive of the claim",
|
|
164
|
+
});
|
|
165
|
+
onProgress?.(` verify: kept "${finding.title}" but removed its citation — the cited passages do not support the claim`);
|
|
166
|
+
}
|
|
133
167
|
}
|
|
134
168
|
else {
|
|
135
169
|
verdicts.set(finding, "drop");
|
|
@@ -144,6 +178,8 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
144
178
|
}
|
|
145
179
|
}));
|
|
146
180
|
// Preserve original order.
|
|
147
|
-
const kept = findings
|
|
148
|
-
|
|
181
|
+
const kept = findings
|
|
182
|
+
.filter((finding) => verdicts.get(finding) === "keep")
|
|
183
|
+
.map((finding) => replacements.get(finding) ?? finding);
|
|
184
|
+
return { kept, dropped, citationStripped, cost, tokens, model };
|
|
149
185
|
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { appendFile, mkdir, readFile, rmdir } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* Why a call was refused before it executed. Recorded WITHOUT the offending
|
|
5
|
+
* input: a rejected query or URL may contain exactly the sensitive material
|
|
6
|
+
* the sanitizer refused to send, so only the reason class is audited.
|
|
7
|
+
*/
|
|
8
|
+
export const RESEARCH_REJECTION_REASONS = [
|
|
9
|
+
"query-rejected",
|
|
10
|
+
"url-rejected",
|
|
11
|
+
"budget-exhausted",
|
|
12
|
+
];
|
|
3
13
|
const LOCK_RETRIES = 200;
|
|
4
14
|
const LOCK_DELAY_MS = 10;
|
|
15
|
+
const MAX_AUDITED_PASSAGE_CHARACTERS = 20_000;
|
|
5
16
|
function delay(ms) {
|
|
6
17
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
18
|
}
|
|
@@ -18,13 +29,19 @@ function boundedResult(result) {
|
|
|
18
29
|
sourceKind: result.sourceKind,
|
|
19
30
|
title: result.title.slice(0, 240),
|
|
20
31
|
url: result.url.slice(0, 2_000),
|
|
21
|
-
|
|
32
|
+
// This equals the direct-fetch document ceiling, so the artifact preserves the
|
|
33
|
+
// exact bounded text shown to the reviewer without ever storing the raw page.
|
|
34
|
+
passage: result.passage.slice(0, MAX_AUDITED_PASSAGE_CHARACTERS),
|
|
22
35
|
...(result.availability?.length
|
|
23
36
|
? { availability: result.availability.slice(0, 20).map((value) => value.slice(0, 240)) }
|
|
24
37
|
: {}),
|
|
25
38
|
...(result.framework ? { framework: result.framework.slice(0, 240) } : {}),
|
|
26
39
|
...(result.language ? { language: result.language } : {}),
|
|
27
40
|
...(result.symbol ? { symbol: result.symbol.slice(0, 240) } : {}),
|
|
41
|
+
...(result.previousPassageId
|
|
42
|
+
? { previousPassageId: result.previousPassageId.slice(0, 240) }
|
|
43
|
+
: {}),
|
|
44
|
+
...(result.nextPassageId ? { nextPassageId: result.nextPassageId.slice(0, 240) } : {}),
|
|
28
45
|
};
|
|
29
46
|
}
|
|
30
47
|
/** Shared append-only audit and global request budget for all MCP processes in one review. */
|
|
@@ -90,6 +107,14 @@ export class ResearchAudit {
|
|
|
90
107
|
await this.withLock(async () => {
|
|
91
108
|
const used = await this.reservationCount();
|
|
92
109
|
if (used >= this.maxCalls) {
|
|
110
|
+
// Rejected events do not count as reservations, so recording the refusal
|
|
111
|
+
// cannot itself consume (or extend) the budget.
|
|
112
|
+
await this.append({
|
|
113
|
+
type: "rejected",
|
|
114
|
+
tool,
|
|
115
|
+
reason: "budget-exhausted",
|
|
116
|
+
timestamp: new Date().toISOString(),
|
|
117
|
+
});
|
|
93
118
|
throw new Error(`Documentation research call budget exhausted (${this.maxCalls})`);
|
|
94
119
|
}
|
|
95
120
|
if (!this.path)
|
|
@@ -125,6 +150,15 @@ export class ResearchAudit {
|
|
|
125
150
|
timestamp: new Date().toISOString(),
|
|
126
151
|
});
|
|
127
152
|
}
|
|
153
|
+
/** Record a call refused before execution — reason class only, never the input. */
|
|
154
|
+
async rejected(tool, reason) {
|
|
155
|
+
await this.append({
|
|
156
|
+
type: "rejected",
|
|
157
|
+
tool,
|
|
158
|
+
reason,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
128
162
|
}
|
|
129
163
|
export async function readResearchAudit(path) {
|
|
130
164
|
let contents = "";
|
|
@@ -133,10 +167,11 @@ export async function readResearchAudit(path) {
|
|
|
133
167
|
}
|
|
134
168
|
catch (error) {
|
|
135
169
|
if (error.code === "ENOENT")
|
|
136
|
-
return [];
|
|
170
|
+
return { records: [], rejections: [] };
|
|
137
171
|
throw error;
|
|
138
172
|
}
|
|
139
173
|
const records = [];
|
|
174
|
+
const rejections = [];
|
|
140
175
|
for (const line of contents.split("\n")) {
|
|
141
176
|
if (!line)
|
|
142
177
|
continue;
|
|
@@ -154,10 +189,14 @@ export async function readResearchAudit(path) {
|
|
|
154
189
|
error: event.error,
|
|
155
190
|
});
|
|
156
191
|
}
|
|
192
|
+
if (event.type === "rejected" &&
|
|
193
|
+
RESEARCH_REJECTION_REASONS.includes(event.reason)) {
|
|
194
|
+
rejections.push({ tool: event.tool, reason: event.reason });
|
|
195
|
+
}
|
|
157
196
|
}
|
|
158
197
|
catch {
|
|
159
198
|
// Ignore a partial final line from a process that was terminated mid-write.
|
|
160
199
|
}
|
|
161
200
|
}
|
|
162
|
-
return records;
|
|
201
|
+
return { records, rejections };
|
|
163
202
|
}
|
|
@@ -17,7 +17,8 @@ Usage:
|
|
|
17
17
|
The serve command uses BRAVE_SEARCH_API_KEY for scoped web discovery, fetches only
|
|
18
18
|
allowlisted official pages, and optionally falls back to a local index. Expo-provider
|
|
19
19
|
searches use Expo's public documentation index. Its fetch_platform_doc tool can fetch
|
|
20
|
-
one exact allowlisted documentation URL without a search key
|
|
20
|
+
one exact allowlisted documentation URL without a search key and return focused,
|
|
21
|
+
section, or bounded-document extracted context. The update command is
|
|
21
22
|
an optional offline crawler for operator-managed fallback indexes.
|
|
22
23
|
`);
|
|
23
24
|
}
|
|
@@ -6,6 +6,7 @@ import { buildSearchIndex, searchDocumentation } from "./search-index.js";
|
|
|
6
6
|
/** Specific corpora precede their broader host/path parents during URL inference. */
|
|
7
7
|
const DIRECT_PROVIDER_ORDER = [
|
|
8
8
|
"apple-releases",
|
|
9
|
+
"sdwebimage",
|
|
9
10
|
"apple",
|
|
10
11
|
"swift-evolution",
|
|
11
12
|
"android-releases",
|
|
@@ -28,6 +29,7 @@ const DIRECT_SOURCE_KIND = {
|
|
|
28
29
|
apple: "official-api",
|
|
29
30
|
"apple-releases": "release-notes",
|
|
30
31
|
"swift-evolution": "official-guide",
|
|
32
|
+
sdwebimage: "official-api",
|
|
31
33
|
android: "official-api",
|
|
32
34
|
"android-releases": "release-notes",
|
|
33
35
|
media3: "official-guide",
|
|
@@ -44,6 +46,48 @@ const DIRECT_SOURCE_KIND = {
|
|
|
44
46
|
"react-native-screens": "official-guide",
|
|
45
47
|
"react-native-worklets": "official-guide",
|
|
46
48
|
};
|
|
49
|
+
export const DIRECT_DOCUMENT_CONTEXT_MODES = ["focused", "section", "document"];
|
|
50
|
+
const SECTION_CONTEXT_CHARACTERS = 12_000;
|
|
51
|
+
const DOCUMENT_CONTEXT_CHARACTERS = 20_000;
|
|
52
|
+
function withScore(chunk, score = 0) {
|
|
53
|
+
return { ...chunk, score };
|
|
54
|
+
}
|
|
55
|
+
function rankedAnchor(chunks, document, provider, query) {
|
|
56
|
+
if (!query?.trim())
|
|
57
|
+
return chunks[0] ? withScore(chunks[0]) : undefined;
|
|
58
|
+
const index = buildSearchIndex(chunks, 1);
|
|
59
|
+
return (searchDocumentation(index, query, {
|
|
60
|
+
platform: document.platform,
|
|
61
|
+
providers: [provider],
|
|
62
|
+
limit: 1,
|
|
63
|
+
})[0] ?? (chunks[0] ? withScore(chunks[0]) : undefined));
|
|
64
|
+
}
|
|
65
|
+
function contiguousWindow(body, anchor, maxCharacters) {
|
|
66
|
+
if (body.length <= maxCharacters)
|
|
67
|
+
return body;
|
|
68
|
+
const exactIndex = body.indexOf(anchor);
|
|
69
|
+
const prefixIndex = exactIndex >= 0 ? exactIndex : body.indexOf(anchor.slice(0, 160));
|
|
70
|
+
const anchorIndex = prefixIndex >= 0 ? prefixIndex : 0;
|
|
71
|
+
const centeredStart = Math.max(0, anchorIndex - Math.floor(maxCharacters / 3));
|
|
72
|
+
let start = centeredStart;
|
|
73
|
+
const previousBoundary = body.lastIndexOf("\n\n", centeredStart);
|
|
74
|
+
if (previousBoundary >= Math.max(0, centeredStart - 300))
|
|
75
|
+
start = previousBoundary + 2;
|
|
76
|
+
let end = Math.min(body.length, start + maxCharacters);
|
|
77
|
+
const nextBoundary = body.indexOf("\n\n", end - 300);
|
|
78
|
+
if (nextBoundary >= 0 && nextBoundary <= start + maxCharacters)
|
|
79
|
+
end = nextBoundary;
|
|
80
|
+
return body.slice(start, end).trim();
|
|
81
|
+
}
|
|
82
|
+
function focusedResults(chunks, anchor, limit) {
|
|
83
|
+
if (!anchor)
|
|
84
|
+
return [];
|
|
85
|
+
const anchorIndex = Math.max(0, chunks.findIndex((chunk) => chunk.id === anchor.id));
|
|
86
|
+
const start = Math.max(0, Math.min(anchorIndex - Math.floor(limit / 2), chunks.length - limit));
|
|
87
|
+
return chunks
|
|
88
|
+
.slice(start, start + limit)
|
|
89
|
+
.map((chunk) => withScore(chunk, chunk.id === anchor.id ? anchor.score : 0));
|
|
90
|
+
}
|
|
47
91
|
/** Resolve a caller-supplied URL against the fixed provider allowlist. */
|
|
48
92
|
export function resolveDirectDocumentationTarget(rawUrl, providerHint) {
|
|
49
93
|
assertSafeDocumentationUrlShape(rawUrl);
|
|
@@ -76,25 +120,41 @@ export async function fetchDocumentationUrl(rawUrl, options = {}) {
|
|
|
76
120
|
const indexedAt = new Date().toISOString();
|
|
77
121
|
const chunks = chunkDocument(document, indexedAt);
|
|
78
122
|
const limit = Math.min(5, Math.max(1, options.limit ?? 3));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
providers: [target.provider],
|
|
85
|
-
limit,
|
|
86
|
-
});
|
|
87
|
-
if (results.length === 0) {
|
|
88
|
-
results = chunks.slice(0, limit).map((chunk) => ({ ...chunk, score: 0 }));
|
|
89
|
-
}
|
|
123
|
+
const mode = options.context ?? "section";
|
|
124
|
+
const anchor = rankedAnchor(chunks, document, target.provider, options.query);
|
|
125
|
+
let results = [];
|
|
126
|
+
if (mode === "focused") {
|
|
127
|
+
results = focusedResults(chunks, anchor, limit);
|
|
90
128
|
}
|
|
91
|
-
else {
|
|
92
|
-
|
|
129
|
+
else if (anchor) {
|
|
130
|
+
const maxCharacters = mode === "document" ? DOCUMENT_CONTEXT_CHARACTERS : SECTION_CONTEXT_CHARACTERS;
|
|
131
|
+
const passage = mode === "document"
|
|
132
|
+
? document.body.slice(0, maxCharacters).trim()
|
|
133
|
+
: contiguousWindow(document.body, anchor.passage, maxCharacters);
|
|
134
|
+
const { previousPassageId: _previous, nextPassageId: _next, ...anchorWithoutNeighbors } = anchor;
|
|
135
|
+
results = [
|
|
136
|
+
{
|
|
137
|
+
...anchorWithoutNeighbors,
|
|
138
|
+
id: `${anchor.id.replace(/#\d+$/, "")}#${mode}`,
|
|
139
|
+
passage,
|
|
140
|
+
},
|
|
141
|
+
];
|
|
93
142
|
}
|
|
143
|
+
const returnedCharacters = results.reduce((total, result) => total + result.passage.length, 0);
|
|
94
144
|
return {
|
|
95
145
|
provider: target.provider,
|
|
96
146
|
sourceKind: target.sourceKind,
|
|
97
147
|
canonicalUrl: target.url.href,
|
|
148
|
+
context: {
|
|
149
|
+
mode,
|
|
150
|
+
returnedCharacters,
|
|
151
|
+
documentCharacters: document.body.length,
|
|
152
|
+
truncated: returnedCharacters < document.body.length,
|
|
153
|
+
...(anchor ? { anchorPassageId: anchor.id } : {}),
|
|
154
|
+
availablePassageCount: chunks.length,
|
|
155
|
+
availablePassageIds: chunks.slice(0, 100).map((chunk) => chunk.id),
|
|
156
|
+
...(chunks.length > 100 ? { passageIdsTruncated: true } : {}),
|
|
157
|
+
},
|
|
98
158
|
results,
|
|
99
159
|
};
|
|
100
160
|
}
|
|
@@ -53,8 +53,8 @@ export function extractExpoAlgoliaDocuments(json) {
|
|
|
53
53
|
}
|
|
54
54
|
return documents;
|
|
55
55
|
}
|
|
56
|
-
async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
|
|
57
|
-
const response = await
|
|
56
|
+
async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes, fetchImplementation) {
|
|
57
|
+
const response = await fetchImplementation(EXPO_ALGOLIA_ENDPOINT, {
|
|
58
58
|
method: "POST",
|
|
59
59
|
redirect: "error",
|
|
60
60
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -83,7 +83,7 @@ async function fetchExpoAlgolia(query, limit, timeoutMs, maxResponseBytes) {
|
|
|
83
83
|
const body = await readBodyWithLimit(response, maxResponseBytes);
|
|
84
84
|
return extractExpoAlgoliaDocuments(body);
|
|
85
85
|
}
|
|
86
|
-
export async function searchExpoAlgolia(query, limit) {
|
|
86
|
+
export async function searchExpoAlgolia(query, limit, fetchImplementation = fetch) {
|
|
87
87
|
const normalized = query.replace(/\s+/g, " ").trim();
|
|
88
88
|
if (!normalized || normalized.length > 300) {
|
|
89
89
|
throw new Error("Expo Algolia query must contain between 1 and 300 characters");
|
|
@@ -91,5 +91,5 @@ export async function searchExpoAlgolia(query, limit) {
|
|
|
91
91
|
if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
|
|
92
92
|
throw new Error("Expo Algolia result limit must be between 1 and 10");
|
|
93
93
|
}
|
|
94
|
-
return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000);
|
|
94
|
+
return fetchExpoAlgolia(normalized, limit, 5000, 1_000_000, fetchImplementation);
|
|
95
95
|
}
|
|
@@ -128,11 +128,14 @@ export function chunkDocument(document, indexedAt, targetCharacters = 1400, over
|
|
|
128
128
|
.update(`${document.provider ?? document.platform}\0${document.url}\0${document.title}`)
|
|
129
129
|
.digest("hex")
|
|
130
130
|
.slice(0, 16);
|
|
131
|
+
const passageId = (chunkIndex) => `${document.provider ?? document.platform}:${documentId}#${chunkIndex}`;
|
|
131
132
|
return passages.map((passage, chunkIndex) => ({
|
|
132
133
|
...document,
|
|
133
134
|
body: undefined,
|
|
134
|
-
id:
|
|
135
|
+
id: passageId(chunkIndex),
|
|
135
136
|
passage,
|
|
137
|
+
...(chunkIndex > 0 ? { previousPassageId: passageId(chunkIndex - 1) } : {}),
|
|
138
|
+
...(chunkIndex + 1 < passages.length ? { nextPassageId: passageId(chunkIndex + 1) } : {}),
|
|
136
139
|
indexedAt,
|
|
137
140
|
}));
|
|
138
141
|
}
|
|
@@ -130,6 +130,43 @@ export const swiftEvolutionProvider = {
|
|
|
130
130
|
return documentUrl.hostname === "github.com" ? "markdown" : "html";
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
|
+
const sdWebImageDocumentPrefix = "/documentation/sdwebimage";
|
|
134
|
+
/** SDWebImage publishes a static DocC archive whose visible routes are JS shells. */
|
|
135
|
+
export const sdWebImageProvider = {
|
|
136
|
+
id: "sdwebimage",
|
|
137
|
+
platform: "apple",
|
|
138
|
+
displayName: "SDWebImage documentation",
|
|
139
|
+
accepts(url) {
|
|
140
|
+
return (isSecurePublicUrl(url, "sdwebimage.github.io") &&
|
|
141
|
+
hasAllowedPath(url, [sdWebImageDocumentPrefix]));
|
|
142
|
+
},
|
|
143
|
+
acceptsRequest(url) {
|
|
144
|
+
if (this.accepts(url))
|
|
145
|
+
return true;
|
|
146
|
+
if (!isSecurePublicUrl(url, "sdwebimage.github.io") ||
|
|
147
|
+
!url.pathname.startsWith(`/data${sdWebImageDocumentPrefix}`) ||
|
|
148
|
+
!url.pathname.endsWith(".json")) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const correspondingDocumentUrl = new URL(url.href);
|
|
152
|
+
correspondingDocumentUrl.pathname = url.pathname.slice("/data".length).replace(/\.json$/, "");
|
|
153
|
+
return hasAllowedPath(correspondingDocumentUrl, [sdWebImageDocumentPrefix]);
|
|
154
|
+
},
|
|
155
|
+
canonicalize(url) {
|
|
156
|
+
const hadTrailingSlash = url.pathname.endsWith("/");
|
|
157
|
+
const canonical = canonicalizeDocumentationUrl(url);
|
|
158
|
+
if (hadTrailingSlash && !canonical.pathname.endsWith("/"))
|
|
159
|
+
canonical.pathname += "/";
|
|
160
|
+
return canonical;
|
|
161
|
+
},
|
|
162
|
+
requestUrl(documentUrl) {
|
|
163
|
+
const documentPath = documentUrl.pathname.replace(/\/$/, "").toLowerCase();
|
|
164
|
+
return new URL(`/data${documentPath}.json`, documentUrl.origin);
|
|
165
|
+
},
|
|
166
|
+
responseFormat() {
|
|
167
|
+
return "docc-json";
|
|
168
|
+
},
|
|
169
|
+
};
|
|
133
170
|
const androidPrefixes = [
|
|
134
171
|
"/build",
|
|
135
172
|
"/develop",
|
|
@@ -272,6 +309,7 @@ const providers = {
|
|
|
272
309
|
apple: appleProvider,
|
|
273
310
|
"apple-releases": appleReleasesProvider,
|
|
274
311
|
"swift-evolution": swiftEvolutionProvider,
|
|
312
|
+
sdwebimage: sdWebImageProvider,
|
|
275
313
|
android: androidProvider,
|
|
276
314
|
"android-releases": androidReleasesProvider,
|
|
277
315
|
media3: media3Provider,
|
|
@@ -71,7 +71,20 @@ function isApiAnchor(value) {
|
|
|
71
71
|
return (/[a-z][A-Z]/.test(value) ||
|
|
72
72
|
/[A-Z][A-Za-z0-9_]{1,}/.test(value) ||
|
|
73
73
|
/[._:$#()]/.test(value) ||
|
|
74
|
-
/_[A-Z0-9]/.test(value)
|
|
74
|
+
/_[A-Z0-9]/.test(value) ||
|
|
75
|
+
// Hyphenated package and module names (expo-camera, react-native-screens).
|
|
76
|
+
/[a-z0-9]-[a-z]/.test(value));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A short, all-lowercase concept phrase ("gradle configuration cache",
|
|
80
|
+
* "coroutine cancellation cooperative"). Guide, release-note, and Expo
|
|
81
|
+
* documentation topics frequently have no CamelCase symbol; two or more plain
|
|
82
|
+
* dictionary-shaped words carry no more outbound capacity than a symbol query
|
|
83
|
+
* (same token, length, entropy, and secret checks apply) and are accepted.
|
|
84
|
+
* A single generic word still fails closed.
|
|
85
|
+
*/
|
|
86
|
+
function isPlainConceptPhrase(tokens) {
|
|
87
|
+
return tokens.length >= 2 && tokens.every((token) => /^[a-z][a-z0-9]{2,23}$/.test(token));
|
|
75
88
|
}
|
|
76
89
|
/**
|
|
77
90
|
* Convert an agent-authored search into a short API-symbol query. Dangerous shapes
|
|
@@ -100,8 +113,8 @@ export function sanitizeDocumentationQuery(rawQuery) {
|
|
|
100
113
|
return !PROSE_STOP_WORDS.has(token.toLowerCase());
|
|
101
114
|
});
|
|
102
115
|
const unique = [...new Set(candidates)].slice(0, MAX_QUERY_TOKENS);
|
|
103
|
-
if (!unique.some(isApiAnchor)) {
|
|
104
|
-
throw new Error("Query must include an API-like symbol or
|
|
116
|
+
if (!unique.some(isApiAnchor) && !isPlainConceptPhrase(unique)) {
|
|
117
|
+
throw new Error("Query must include an API-like symbol or a multi-word concept phrase");
|
|
105
118
|
}
|
|
106
119
|
const sanitized = unique.join(" ").slice(0, MAX_QUERY_CHARACTERS).trim();
|
|
107
120
|
if (!sanitized)
|