aiki-cli 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -6
- package/README.md +119 -30
- package/dist/bench/arms.js +10 -9
- package/dist/bench/harness.js +13 -10
- package/dist/bench/idea-lane-rotation.js +237 -0
- package/dist/bench/idea-v3-bench.js +506 -0
- package/dist/bench/idea-v3-rating.js +582 -0
- package/dist/bench/results.js +10 -3
- package/dist/bench/scoring/decision-insights.js +112 -0
- package/dist/bench/scoring/seeded-bugs.js +4 -0
- package/dist/cli/bench.js +180 -3
- package/dist/cli/doctor.js +56 -24
- package/dist/cli/index.js +31 -6
- package/dist/cli/resolve.js +7 -6
- package/dist/cli/resume.js +18 -0
- package/dist/cli/run.js +66 -8
- package/dist/council/view.js +446 -117
- package/dist/orchestration/calculations.js +97 -0
- package/dist/orchestration/context.js +37 -9
- package/dist/orchestration/decision-dossier.js +320 -0
- package/dist/orchestration/decision-graph.js +256 -0
- package/dist/orchestration/engine.js +5 -2
- package/dist/orchestration/evidence-pack.js +46 -0
- package/dist/orchestration/idea-lanes.js +20 -0
- package/dist/orchestration/jsonStage.js +72 -0
- package/dist/orchestration/legacy-idea-adapter.js +102 -0
- package/dist/orchestration/modes.js +39 -0
- package/dist/orchestration/preflight.js +203 -0
- package/dist/orchestration/quick-analysis.js +93 -0
- package/dist/orchestration/stages/cr-ladder.js +80 -0
- package/dist/orchestration/stages/s10-render.js +574 -150
- package/dist/orchestration/stages/s4-analyze.js +12 -7
- package/dist/orchestration/stages/s5-drift.js +9 -9
- package/dist/orchestration/stages/s6-positions.js +10 -0
- package/dist/orchestration/stages/s7-decision-graph.js +76 -0
- package/dist/orchestration/stages/s8-verify.js +153 -46
- package/dist/orchestration/stages/s8b-rebuttal.js +208 -0
- package/dist/orchestration/stages/s9-judge.js +329 -108
- package/dist/orchestration/stages/s9b-plan.js +118 -85
- package/dist/orchestration/url-sources.js +200 -0
- package/dist/providers/codex.js +2 -1
- package/dist/providers/spawn.js +5 -0
- package/dist/schemas/index.js +638 -15
- package/dist/skills/idea-refinement/analyst.md +18 -14
- package/dist/skills/idea-refinement/economics-delivery.md +7 -0
- package/dist/skills/idea-refinement/market-adoption.md +7 -0
- package/dist/storage/runs.js +12 -4
- package/dist/tui/app.js +53 -14
- package/dist/tui/format.js +4 -5
- package/dist/tui/smart-entry.js +17 -1
- package/dist/tui/timeline.js +2 -4
- package/dist/workflows/code-review.js +4 -2
- package/dist/workflows/idea-refinement.js +118 -46
- package/package.json +12 -4
- package/dist/orchestration/stages/s0-grill.js +0 -79
- package/dist/orchestration/stages/s1-intent.js +0 -25
- package/dist/orchestration/stages/s2-misread.js +0 -76
- package/dist/orchestration/stages/s3-prompts.js +0 -55
- package/dist/orchestration/stages/s6-claims.js +0 -56
- package/dist/orchestration/stages/s7-disagreement.js +0 -134
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
// S9b — idea validation plan. This is the one report-v3 model call after the judge: it turns the
|
|
2
2
|
// adjudicated risks, blind spots, and open questions into anchored validation actions. Rendering stays
|
|
3
|
-
// deterministic; if the planner fails or produces unanchored actions, we write
|
|
4
|
-
import { ActionPlan } from '../../schemas/index.js';
|
|
3
|
+
// deterministic; if the planner fails or produces unanchored actions, we write flagged unavailability.
|
|
4
|
+
import { ActionPlan, FeatureBacklog, ImplementationPlan } from '../../schemas/index.js';
|
|
5
|
+
import { z } from 'zod';
|
|
5
6
|
import { BudgetExceeded, isFatal } from '../context.js';
|
|
6
7
|
import { jsonCall } from '../jsonStage.js';
|
|
7
8
|
import { loadSkill } from '../skills.js';
|
|
8
9
|
import { mergeOpenQuestions } from './s10-render.js';
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const PlannerOutput = z.object({
|
|
11
|
+
actions: z.array(z.object({
|
|
12
|
+
order: z.number().int().min(1).optional(),
|
|
13
|
+
action: z.string().min(1),
|
|
14
|
+
why: z.string().min(1),
|
|
15
|
+
validates: z.string().min(1),
|
|
16
|
+
effort: z.string().min(1).optional(),
|
|
17
|
+
kill_signal: z.string().min(1),
|
|
18
|
+
}).strict()).min(1).max(7),
|
|
19
|
+
sequencing_note: z.string().min(1),
|
|
20
|
+
feature_backlog: FeatureBacklog.optional(),
|
|
21
|
+
implementation_plan: ImplementationPlan.optional(),
|
|
22
|
+
}).strict();
|
|
23
|
+
const S9B_PROMPT = `ROLE: Validation planner. You write the requested practical outputs after an idea
|
|
24
|
+
council has debated and judged the idea. The actions list remains validation work: test unsettled risks,
|
|
25
|
+
blind spots, or open questions, with the cheapest decisive test first.{{SKILL}}
|
|
12
26
|
|
|
13
27
|
Output ONLY JSON matching the ActionPlan schema:
|
|
14
28
|
- actions: 1-7 ordered actions, each imperative and concrete.
|
|
15
29
|
- validates MUST anchor to one of:
|
|
16
|
-
- a
|
|
30
|
+
- a graph claim id from upheld_risks, e.g. "G3"
|
|
17
31
|
- a blind spot label as "blind:<label>"
|
|
18
32
|
- an open-question prefix as "Q:<question prefix>"
|
|
19
33
|
- why ties the action to the risk, blind spot, or question.
|
|
20
34
|
- kill_signal is the result that should stop or reshape the idea.
|
|
35
|
+
- Preserve the chair's numeric distinctions: operating break-even is not capital payback, and a target cap
|
|
36
|
+
is not a known cost. Do not introduce or reinterpret a number that is absent from decision_snapshot.
|
|
21
37
|
- sequencing_note explains why this order is cheapest and decisive.
|
|
38
|
+
- Read requested_outputs in CONTEXT. When it includes FEATURE_BACKLOG, include feature_backlog with
|
|
39
|
+
must/should/later items {feature,user_value,rationale,effort:S|M|L} and wont items {feature,reason}.
|
|
40
|
+
Prioritize the smallest judge-impressing golden path; MUST is required, not a wishlist.
|
|
41
|
+
- When requested_outputs includes IMPLEMENTATION_PLAN, include implementation_plan.milestones with
|
|
42
|
+
{order,timebox,outcome,tasks,acceptance_test}. This is a concrete build sequence, not validation prose.
|
|
43
|
+
- Do not omit a requested output and do not invent an unrequested product surface.
|
|
22
44
|
|
|
23
45
|
CONTEXT: {{CONTEXT_JSON}}`;
|
|
24
46
|
function clip(s, n) {
|
|
@@ -31,29 +53,37 @@ function sevRank(s) {
|
|
|
31
53
|
function norm(s) {
|
|
32
54
|
return s.trim().toLowerCase();
|
|
33
55
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
function blindAnchor(b) {
|
|
38
|
-
return `blind:${b}`;
|
|
39
|
-
}
|
|
40
|
-
function upheldRisks(map, judgeReport) {
|
|
41
|
-
const claimById = new Map([...map.consensus, ...map.unique].map((c) => [c.id, c.statement]));
|
|
56
|
+
function unresolvedRisks(graph, judgeReport) {
|
|
57
|
+
const claimById = new Map(graph.claims.map((claim) => [claim.id, claim]));
|
|
42
58
|
const rulingById = new Map(judgeReport.adjudications.map((a) => [a.id, a]));
|
|
43
|
-
|
|
44
|
-
.map((
|
|
45
|
-
const adj = rulingById.get(
|
|
59
|
+
const risks = graph.claims
|
|
60
|
+
.map((claim) => {
|
|
61
|
+
const adj = rulingById.get(claim.id);
|
|
46
62
|
if (adj?.ruling !== 'UPHOLD')
|
|
47
63
|
return null;
|
|
48
64
|
return {
|
|
49
|
-
id:
|
|
50
|
-
assumption:
|
|
51
|
-
severity:
|
|
65
|
+
id: claim.id,
|
|
66
|
+
assumption: claim.proposition,
|
|
67
|
+
severity: claim.sensitivity === 'DECISIVE' ? 'HIGH' : claim.sensitivity === 'MATERIAL' ? 'MED' : 'LOW',
|
|
52
68
|
reasoning: adj.reasoning,
|
|
53
69
|
};
|
|
54
70
|
})
|
|
55
|
-
.filter((
|
|
56
|
-
|
|
71
|
+
.filter((risk) => risk !== null);
|
|
72
|
+
const seen = new Set(risks.map((risk) => risk.id));
|
|
73
|
+
for (const hole of graph.holes.evidence) {
|
|
74
|
+
if (seen.has(hole.claim_id))
|
|
75
|
+
continue;
|
|
76
|
+
const claim = claimById.get(hole.claim_id);
|
|
77
|
+
if (!claim)
|
|
78
|
+
continue;
|
|
79
|
+
risks.push({
|
|
80
|
+
id: claim.id,
|
|
81
|
+
assumption: claim.proposition,
|
|
82
|
+
severity: claim.sensitivity === 'DECISIVE' ? 'HIGH' : claim.sensitivity === 'MATERIAL' ? 'MED' : 'LOW',
|
|
83
|
+
reasoning: hole.reason,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return risks.sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
|
|
57
87
|
}
|
|
58
88
|
export function buildActionPlannerPrompt(input, skill) {
|
|
59
89
|
return S9B_PROMPT
|
|
@@ -62,7 +92,7 @@ export function buildActionPlannerPrompt(input, skill) {
|
|
|
62
92
|
}
|
|
63
93
|
export function validAnchor(anchor, anchors) {
|
|
64
94
|
const a = anchor.trim();
|
|
65
|
-
if (anchors.
|
|
95
|
+
if (anchors.claimIds.includes(a))
|
|
66
96
|
return true;
|
|
67
97
|
if (a.toLowerCase().startsWith('blind:')) {
|
|
68
98
|
const label = norm(a.slice('blind:'.length));
|
|
@@ -80,104 +110,107 @@ export function validAnchor(anchor, anchors) {
|
|
|
80
110
|
}
|
|
81
111
|
return false;
|
|
82
112
|
}
|
|
83
|
-
export function anchoredActionPlan(plan, anchors) {
|
|
113
|
+
export function anchoredActionPlan(plan, anchors, requestedOutputs = []) {
|
|
84
114
|
const actions = plan.actions
|
|
85
115
|
.filter((a) => validAnchor(a.validates, anchors))
|
|
86
116
|
.sort((a, b) => a.order - b.order)
|
|
87
117
|
.map((a, i) => ({ ...a, order: i + 1 }));
|
|
88
118
|
if (actions.length === 0)
|
|
89
119
|
return null;
|
|
90
|
-
|
|
120
|
+
if (requestedOutputs.includes('FEATURE_BACKLOG') && !plan.feature_backlog)
|
|
121
|
+
return null;
|
|
122
|
+
if (requestedOutputs.includes('IMPLEMENTATION_PLAN') && !plan.implementation_plan)
|
|
123
|
+
return null;
|
|
124
|
+
return {
|
|
125
|
+
actions,
|
|
126
|
+
sequencing_note: plan.sequencing_note,
|
|
127
|
+
...(requestedOutputs.includes('FEATURE_BACKLOG') && plan.feature_backlog ? { feature_backlog: plan.feature_backlog } : {}),
|
|
128
|
+
...(requestedOutputs.includes('IMPLEMENTATION_PLAN') && plan.implementation_plan ? { implementation_plan: plan.implementation_plan } : {}),
|
|
129
|
+
};
|
|
91
130
|
}
|
|
92
|
-
export function
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
for (const b of map.blind_spots) {
|
|
106
|
-
actions.push({
|
|
107
|
-
order: actions.length + 1,
|
|
108
|
-
action: `Answer the ${b} gap with one concrete evidence source.`,
|
|
109
|
-
why: 'No analyst examined this dimension enough to rely on it.',
|
|
110
|
-
validates: blindAnchor(b),
|
|
111
|
-
effort: 'S',
|
|
112
|
-
kill_signal: 'The answer exposes a hard blocker or makes the target user/use case incoherent.',
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
for (const q of openQuestions) {
|
|
116
|
-
actions.push({
|
|
117
|
-
order: actions.length + 1,
|
|
118
|
-
action: `Resolve this question: ${clip(q, 140)}`,
|
|
119
|
-
why: 'The analysts identified it as an answer that could change the verdict.',
|
|
120
|
-
validates: questionAnchor(q),
|
|
121
|
-
effort: 'S',
|
|
122
|
-
kill_signal: 'The answer contradicts the value proposition or removes the target user.',
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
if (actions.length === 0) {
|
|
126
|
-
const q = `What evidence would change the verdict for ${clip(contract.task, 80)}?`;
|
|
127
|
-
actions.push({
|
|
128
|
-
order: 1,
|
|
129
|
-
action: 'Run one cheap test of the core user need before investing more.',
|
|
130
|
-
why: 'The council produced no unsettled anchored item, so validate the core demand directly.',
|
|
131
|
-
validates: questionAnchor(q),
|
|
132
|
-
effort: 'S',
|
|
133
|
-
kill_signal: 'Target users do not recognize the problem or would not switch from existing alternatives.',
|
|
134
|
-
});
|
|
135
|
-
}
|
|
131
|
+
export function normalizeEffort(raw) {
|
|
132
|
+
const value = raw?.trim().toLowerCase();
|
|
133
|
+
if (value === 's' || value === 'm' || value === 'l')
|
|
134
|
+
return value.toUpperCase();
|
|
135
|
+
const match = value?.match(/(\d+(?:\.\d+)?)(?:\s*-\s*(\d+(?:\.\d+)?))?\s*(minute|hour|day|week|month|year)s?/);
|
|
136
|
+
if (!match)
|
|
137
|
+
return 'M';
|
|
138
|
+
const amount = Number(match[2] ?? match[1]);
|
|
139
|
+
const unit = match[3];
|
|
140
|
+
const days = amount * (unit === 'minute' ? 1 / 1440 : unit === 'hour' ? 1 / 24 : unit === 'day' ? 1 : unit === 'week' ? 7 : unit === 'month' ? 30 : 365);
|
|
141
|
+
return days <= 2 ? 'S' : days <= 14 ? 'M' : 'L';
|
|
142
|
+
}
|
|
143
|
+
export function normalizePlannerOutput(plan) {
|
|
136
144
|
return ActionPlan.parse({
|
|
137
|
-
actions: actions.
|
|
138
|
-
|
|
145
|
+
actions: plan.actions.map((action, i) => ({
|
|
146
|
+
...action,
|
|
147
|
+
order: i + 1,
|
|
148
|
+
effort: normalizeEffort(action.effort),
|
|
149
|
+
})),
|
|
150
|
+
sequencing_note: plan.sequencing_note,
|
|
151
|
+
...(plan.feature_backlog ? { feature_backlog: plan.feature_backlog } : {}),
|
|
152
|
+
...(plan.implementation_plan ? { implementation_plan: plan.implementation_plan } : {}),
|
|
139
153
|
});
|
|
140
154
|
}
|
|
141
|
-
|
|
155
|
+
function unavailablePlan(reason, contract, risks, blindSpots, openQuestions) {
|
|
156
|
+
const unresolved = openQuestions.length ? openQuestions : [
|
|
157
|
+
...risks.map((risk) => `What evidence would resolve whether ${risk.assumption}?`),
|
|
158
|
+
...blindSpots.map((spot) => `What evidence resolves the ${spot} gap?`),
|
|
159
|
+
];
|
|
160
|
+
return {
|
|
161
|
+
kind: 'PlannerUnavailable',
|
|
162
|
+
reason,
|
|
163
|
+
unresolved_questions: (unresolved.length ? unresolved : [`What evidence would change the verdict for ${clip(contract.task, 100)}?`]).slice(0, 10),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export async function s9bPlan(ctx, contract, seats, graph, judgeReport) {
|
|
142
167
|
const openQuestions = mergeOpenQuestions(seats);
|
|
143
|
-
const
|
|
168
|
+
const requestedOutputs = contract.requested_outputs
|
|
169
|
+
?? ['DECISION'];
|
|
170
|
+
const risks = unresolvedRisks(graph, judgeReport);
|
|
171
|
+
const blindSpots = graph.holes.coverage.map((hole) => hole.label);
|
|
144
172
|
const anchors = {
|
|
145
|
-
|
|
146
|
-
blindSpots
|
|
173
|
+
claimIds: risks.map((r) => r.id),
|
|
174
|
+
blindSpots,
|
|
147
175
|
openQuestions,
|
|
148
176
|
};
|
|
149
177
|
const fallback = async (flag) => {
|
|
150
178
|
ctx.addFlag(flag);
|
|
151
|
-
const plan =
|
|
179
|
+
const plan = unavailablePlan(flag === 'plan_skipped' ? 'budget_exhausted' : 'planner_failed', contract, risks, blindSpots, openQuestions);
|
|
152
180
|
await ctx.writer.writeJson('action-plan', plan);
|
|
153
181
|
return plan;
|
|
154
182
|
};
|
|
155
|
-
if (ctx.budget.limit - ctx.budget.used <
|
|
183
|
+
if (ctx.budget.limit - ctx.budget.used < 1)
|
|
156
184
|
return fallback('plan_skipped');
|
|
157
185
|
const prompt = buildActionPlannerPrompt({
|
|
158
186
|
task: contract.task,
|
|
159
187
|
recommendation: judgeReport.recommendation,
|
|
160
188
|
conditions: judgeReport.conditions,
|
|
189
|
+
decision_snapshot: judgeReport.decision_snapshot,
|
|
161
190
|
upheld_risks: risks,
|
|
162
|
-
blind_spots:
|
|
191
|
+
blind_spots: blindSpots,
|
|
163
192
|
open_questions: openQuestions,
|
|
193
|
+
requested_outputs: requestedOutputs,
|
|
164
194
|
}, loadSkill('idea-refinement', 'planner'));
|
|
165
195
|
try {
|
|
166
|
-
const first = await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-plan', prompt,
|
|
167
|
-
|
|
196
|
+
const first = normalizePlannerOutput(await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-plan', prompt, PlannerOutput, {
|
|
197
|
+
repair: ctx.budget.limit - ctx.budget.used >= 2,
|
|
198
|
+
}));
|
|
199
|
+
const anchored = anchoredActionPlan(first, anchors, requestedOutputs);
|
|
168
200
|
if (anchored) {
|
|
169
201
|
await ctx.writer.writeJson('action-plan', anchored);
|
|
170
202
|
return anchored;
|
|
171
203
|
}
|
|
172
204
|
if (ctx.budget.limit - ctx.budget.used < 1)
|
|
173
205
|
return fallback('plan_fallback');
|
|
174
|
-
const repair = `${prompt}\n\n---\nYour previous plan had no actions with valid anchors.\n` +
|
|
175
|
-
`Valid
|
|
206
|
+
const repair = `${prompt}\n\n---\nYour previous plan had no actions with valid anchors or omitted a requested deliverable.\n` +
|
|
207
|
+
`Valid graph claim ids: ${anchors.claimIds.join(', ') || '(none)'}\n` +
|
|
176
208
|
`Valid blind spots: ${anchors.blindSpots.join(' | ') || '(none)'}\n` +
|
|
177
209
|
`Valid open questions: ${anchors.openQuestions.join(' | ') || '(none)'}\n` +
|
|
178
|
-
`
|
|
179
|
-
|
|
180
|
-
const
|
|
210
|
+
`Required outputs: ${requestedOutputs.join(', ')}\n` +
|
|
211
|
+
`Output ONLY corrected JSON with every action anchored and every requested deliverable present.`;
|
|
212
|
+
const repaired = normalizePlannerOutput(await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-anchor-repair', repair, PlannerOutput, { repair: false }));
|
|
213
|
+
const repairedAnchored = anchoredActionPlan(repaired, anchors, requestedOutputs);
|
|
181
214
|
if (repairedAnchored) {
|
|
182
215
|
await ctx.writer.writeJson('action-plan', repairedAnchored);
|
|
183
216
|
return repairedAnchored;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { UrlSourceSet, } from '../schemas/index.js';
|
|
5
|
+
const MAX_SOURCES = 5;
|
|
6
|
+
const MAX_RESPONSE_BYTES = 500_000;
|
|
7
|
+
const MAX_CONTENT_CHARS = 30_000;
|
|
8
|
+
const MAX_REDIRECTS = 3;
|
|
9
|
+
export function extractPublicUrls(input) {
|
|
10
|
+
const matches = input.match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const urls = [];
|
|
13
|
+
for (const match of matches) {
|
|
14
|
+
const cleaned = match.replace(/[.,;:!?\]\)}]+$/g, '');
|
|
15
|
+
if (seen.has(cleaned))
|
|
16
|
+
continue;
|
|
17
|
+
seen.add(cleaned);
|
|
18
|
+
urls.push(cleaned);
|
|
19
|
+
if (urls.length === MAX_SOURCES)
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
return urls;
|
|
23
|
+
}
|
|
24
|
+
function isPrivateAddress(address) {
|
|
25
|
+
if (address === '::' || address === '::1')
|
|
26
|
+
return true;
|
|
27
|
+
if (address.toLowerCase().startsWith('fe80:') || address.toLowerCase().startsWith('fc') || address.toLowerCase().startsWith('fd'))
|
|
28
|
+
return true;
|
|
29
|
+
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
|
|
30
|
+
const ipv4 = mapped ?? (isIP(address) === 4 ? address : undefined);
|
|
31
|
+
if (!ipv4)
|
|
32
|
+
return false;
|
|
33
|
+
const [a, b] = ipv4.split('.').map(Number);
|
|
34
|
+
return a === 0
|
|
35
|
+
|| a === 10
|
|
36
|
+
|| a === 127
|
|
37
|
+
|| (a === 169 && b === 254)
|
|
38
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
39
|
+
|| (a === 192 && b === 168)
|
|
40
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
41
|
+
|| a >= 224;
|
|
42
|
+
}
|
|
43
|
+
async function assertPublicUrl(url, resolveHostname) {
|
|
44
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
45
|
+
throw new Error('only public http/https URLs are supported');
|
|
46
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
47
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname === '169.254.169.254') {
|
|
48
|
+
throw new Error('private or local URLs are not allowed');
|
|
49
|
+
}
|
|
50
|
+
if (isIP(hostname) && isPrivateAddress(hostname))
|
|
51
|
+
throw new Error('private or local URLs are not allowed');
|
|
52
|
+
if (!resolveHostname || isIP(hostname))
|
|
53
|
+
return;
|
|
54
|
+
const addresses = await lookup(hostname, { all: true });
|
|
55
|
+
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
|
|
56
|
+
throw new Error('private or local URLs are not allowed');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function npmRegistryUrl(url) {
|
|
60
|
+
if (url.hostname !== 'npmjs.com' && url.hostname !== 'www.npmjs.com')
|
|
61
|
+
return undefined;
|
|
62
|
+
const match = url.pathname.match(/^\/package\/(.+?)\/?$/);
|
|
63
|
+
if (!match)
|
|
64
|
+
return undefined;
|
|
65
|
+
const name = decodeURIComponent(match[1]);
|
|
66
|
+
return `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
|
|
67
|
+
}
|
|
68
|
+
function decodeHtml(value) {
|
|
69
|
+
const named = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
|
|
70
|
+
return value.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (entity, code) => {
|
|
71
|
+
if (code[0] === '#') {
|
|
72
|
+
const radix = code[1]?.toLowerCase() === 'x' ? 16 : 10;
|
|
73
|
+
const value = Number.parseInt(code.slice(radix === 16 ? 2 : 1), radix);
|
|
74
|
+
return Number.isFinite(value) ? String.fromCodePoint(value) : entity;
|
|
75
|
+
}
|
|
76
|
+
return named[code.toLowerCase()] ?? entity;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function readableHtml(html) {
|
|
80
|
+
const titleMatch = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
|
81
|
+
const title = titleMatch ? decodeHtml(titleMatch[1].replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim() : undefined;
|
|
82
|
+
const content = decodeHtml(html
|
|
83
|
+
.replace(/<(script|style|noscript|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
84
|
+
.replace(/<[^>]+>/g, ' '))
|
|
85
|
+
.replace(/\s+/g, ' ')
|
|
86
|
+
.trim();
|
|
87
|
+
return { title: title || undefined, content };
|
|
88
|
+
}
|
|
89
|
+
function sha256(content) {
|
|
90
|
+
return createHash('sha256').update(content).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
async function readLimited(response) {
|
|
93
|
+
if (!response.body)
|
|
94
|
+
return '';
|
|
95
|
+
const reader = response.body.getReader();
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
let bytes = 0;
|
|
98
|
+
let body = '';
|
|
99
|
+
while (true) {
|
|
100
|
+
const { done, value } = await reader.read();
|
|
101
|
+
if (done)
|
|
102
|
+
return body + decoder.decode();
|
|
103
|
+
bytes += value.byteLength;
|
|
104
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
105
|
+
await reader.cancel();
|
|
106
|
+
throw new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes`);
|
|
107
|
+
}
|
|
108
|
+
body += decoder.decode(value, { stream: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function failure(id, url, accessedAt, status, error, finalUrl) {
|
|
112
|
+
return { id, url, final_url: finalUrl, status, accessed_at: accessedAt, error };
|
|
113
|
+
}
|
|
114
|
+
async function fetchWithRedirects(startUrl, fetchImpl, resolveHostname) {
|
|
115
|
+
let current = new URL(startUrl);
|
|
116
|
+
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
|
|
117
|
+
await assertPublicUrl(current, resolveHostname);
|
|
118
|
+
const response = await fetchImpl(current.toString(), {
|
|
119
|
+
redirect: 'manual',
|
|
120
|
+
headers: { accept: 'text/html,application/json,text/plain;q=0.9,*/*;q=0.1' },
|
|
121
|
+
});
|
|
122
|
+
if (response.status < 300 || response.status >= 400)
|
|
123
|
+
return { response, finalUrl: current.toString() };
|
|
124
|
+
const location = response.headers.get('location');
|
|
125
|
+
if (!location)
|
|
126
|
+
return { response, finalUrl: current.toString() };
|
|
127
|
+
if (redirect === MAX_REDIRECTS)
|
|
128
|
+
throw new Error(`more than ${MAX_REDIRECTS} redirects`);
|
|
129
|
+
current = new URL(location, current);
|
|
130
|
+
}
|
|
131
|
+
throw new Error('redirect limit exceeded');
|
|
132
|
+
}
|
|
133
|
+
async function snapshotOne(sourceUrl, index, options) {
|
|
134
|
+
const id = `U${index + 1}`;
|
|
135
|
+
const accessedAt = (options.now?.() ?? new Date()).toISOString();
|
|
136
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
137
|
+
try {
|
|
138
|
+
const original = new URL(sourceUrl);
|
|
139
|
+
await assertPublicUrl(original, options.fetchImpl === undefined);
|
|
140
|
+
const adapterUrl = npmRegistryUrl(original);
|
|
141
|
+
const { response, finalUrl } = await fetchWithRedirects(adapterUrl ?? sourceUrl, fetchImpl, options.fetchImpl === undefined);
|
|
142
|
+
const declaredLength = Number(response.headers.get('content-length') ?? 0);
|
|
143
|
+
if (declaredLength > MAX_RESPONSE_BYTES) {
|
|
144
|
+
return failure(id, sourceUrl, accessedAt, 'FAILED', `response exceeds ${MAX_RESPONSE_BYTES} bytes`, finalUrl);
|
|
145
|
+
}
|
|
146
|
+
const body = await readLimited(response);
|
|
147
|
+
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() || 'text/plain';
|
|
148
|
+
const blocked = response.status === 403
|
|
149
|
+
|| response.status === 429
|
|
150
|
+
|| response.status === 503
|
|
151
|
+
|| /cdn-cgi\/challenge|just a moment|captcha|access denied/i.test(body);
|
|
152
|
+
if (blocked) {
|
|
153
|
+
return failure(id, sourceUrl, accessedAt, 'BLOCKED', `site blocked automated access (HTTP ${response.status})`, finalUrl);
|
|
154
|
+
}
|
|
155
|
+
if (!response.ok)
|
|
156
|
+
return failure(id, sourceUrl, accessedAt, 'FAILED', `HTTP ${response.status}`, finalUrl);
|
|
157
|
+
let title;
|
|
158
|
+
let content;
|
|
159
|
+
if (adapterUrl) {
|
|
160
|
+
const metadata = JSON.parse(body);
|
|
161
|
+
const name = typeof metadata.name === 'string' ? metadata.name : original.pathname.split('/').at(-1) ?? 'npm package';
|
|
162
|
+
const version = typeof metadata.version === 'string' ? metadata.version : 'unknown version';
|
|
163
|
+
title = `${name} ${version}`;
|
|
164
|
+
content = [
|
|
165
|
+
`Package: ${name}`,
|
|
166
|
+
`Version: ${version}`,
|
|
167
|
+
typeof metadata.description === 'string' ? `Description: ${metadata.description}` : '',
|
|
168
|
+
typeof metadata.homepage === 'string' ? `Homepage: ${metadata.homepage}` : '',
|
|
169
|
+
typeof metadata.readme === 'string' ? `README:\n${metadata.readme}` : '',
|
|
170
|
+
].filter(Boolean).join('\n');
|
|
171
|
+
}
|
|
172
|
+
else if (contentType === 'text/html' || /<html\b|<body\b/i.test(body)) {
|
|
173
|
+
({ title, content } = readableHtml(body));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
content = body.replace(/\s+/g, ' ').trim();
|
|
177
|
+
}
|
|
178
|
+
content = content.slice(0, MAX_CONTENT_CHARS).trim();
|
|
179
|
+
if (!content)
|
|
180
|
+
return failure(id, sourceUrl, accessedAt, 'FAILED', 'source contained no readable text', finalUrl);
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
url: sourceUrl,
|
|
184
|
+
final_url: finalUrl,
|
|
185
|
+
status: 'FETCHED',
|
|
186
|
+
title,
|
|
187
|
+
content_type: contentType,
|
|
188
|
+
accessed_at: accessedAt,
|
|
189
|
+
sha256: sha256(content),
|
|
190
|
+
content,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
return failure(id, sourceUrl, accessedAt, 'FAILED', error instanceof Error ? error.message : String(error));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export async function snapshotUrlSources(input, options = {}) {
|
|
198
|
+
const sources = await Promise.all(extractPublicUrls(input).map((url, index) => snapshotOne(url, index, options)));
|
|
199
|
+
return UrlSourceSet.parse({ sources });
|
|
200
|
+
}
|
package/dist/providers/codex.js
CHANGED
|
@@ -17,7 +17,8 @@ import { runAdapter } from './adapter-core.js';
|
|
|
17
17
|
const codexSpec = {
|
|
18
18
|
id: 'codex',
|
|
19
19
|
buildArgs(req, flags) {
|
|
20
|
-
|
|
20
|
+
// `--search` is a verified root option: it must precede `exec` (Codex 0.144.1).
|
|
21
|
+
const args = req.research ? ['--search', 'exec', '--skip-git-repo-check'] : ['exec', '--skip-git-repo-check'];
|
|
21
22
|
if (req.readOnly !== false && flags.readOnlyFlag === 'sandbox')
|
|
22
23
|
args.push('-s', 'read-only');
|
|
23
24
|
if (flags.model)
|
package/dist/providers/spawn.js
CHANGED
|
@@ -81,6 +81,7 @@ export const spawnCapture = (bin, args, { cwd, timeoutMs, env, signal }) => {
|
|
|
81
81
|
let notFound = false;
|
|
82
82
|
let settled = false;
|
|
83
83
|
const child = spawn(bin, args, { cwd, env, detached: true, stdio: ['ignore', fd, 'pipe'] });
|
|
84
|
+
child.unref(); // a child that survives the group-kill must never keep our process alive post-resolve
|
|
84
85
|
// SIGKILL the whole detached process group. Shared by the timeout and the Ctrl+C abort (T8).
|
|
85
86
|
const killGroup = () => {
|
|
86
87
|
try {
|
|
@@ -99,6 +100,10 @@ export const spawnCapture = (bin, args, { cwd, timeoutMs, env, signal }) => {
|
|
|
99
100
|
const timer = setTimeout(() => {
|
|
100
101
|
timedOut = true;
|
|
101
102
|
killGroup();
|
|
103
|
+
// Bound the call at timeoutMs even if the group-kill missed a detached child holding our pipe/fd
|
|
104
|
+
// (observed with the claude CLI). Waiting on 'close' alone lets one hung call overrun the wall-clock
|
|
105
|
+
// deadline by an unbounded amount; force-resolve instead. A late 'close' is ignored (`settled`).
|
|
106
|
+
finish(null, 'SIGKILL');
|
|
102
107
|
}, timeoutMs);
|
|
103
108
|
// Ctrl+C: kill the in-flight child immediately so no orphaned metered call survives (§472, T8).
|
|
104
109
|
const onAbort = () => killGroup();
|