@getopencrawl/cli 0.0.0-stage → 0.1.0
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 +304 -2
- package/bin/opencrawl.mjs +528 -0
- package/browser.mjs +166 -0
- package/package.json +29 -4
- package/src/answer-contract.mjs +112 -0
- package/src/answer-view.mjs +118 -0
- package/src/capture.mjs +273 -0
- package/src/client.mjs +175 -0
- package/src/connect.mjs +230 -0
- package/src/context.mjs +34 -0
- package/src/display.mjs +4 -0
- package/src/fixtures/letter-connections.json +59 -0
- package/src/fixtures/product-monitor-contract.json +312 -0
- package/src/fixtures/research-completion-hosted.json +502 -0
- package/src/fixtures/research-completion-v1.json +58 -0
- package/src/fixtures/research-completion-v2.json +33 -0
- package/src/fixtures/research-presentation-hosted.json +720 -0
- package/src/jobs.mjs +108 -0
- package/src/monitors.mjs +146 -0
- package/src/product-letter-capabilities.mjs +71 -0
- package/src/product-letter-read.mjs +178 -0
- package/src/product-monitors.mjs +313 -0
- package/src/research-agent.mjs +164 -0
- package/src/research-answer-contract.mjs +3 -0
- package/src/research-answer-view.mjs +7 -0
- package/src/research-completion.mjs +94 -0
- package/src/research-event.mjs +39 -0
- package/src/research.mjs +133 -0
- package/src/sessions.mjs +165 -0
- package/src/tasks.mjs +115 -0
- package/src/uploads.mjs +141 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Optional, versioned run metadata. It never changes the validity of literal findings. */
|
|
2
|
+
const POLICY_SHA256 = '1e465f634773191ff5e101b9589547d8af81c9c30f63fbc4febea477d58e009f';
|
|
3
|
+
const POLICY_V2_SHA256 = 'e8793d22da1e547e689b17053f3cc7c8094f25ec77add02c79ce11243825dc21';
|
|
4
|
+
const POLICY_V3_SHA256 = '0a8dd4f97f60eea5afb38bfbc83e86fe6446610b2e3aa659e500c2c46c8c513b';
|
|
5
|
+
const LIMITS = { model_calls: 5, web_searches: 5, pages: 9, page_requests: 12, page_bytes: 6291456, work_seconds: 360, crawl_seconds: 60 };
|
|
6
|
+
const LIMITS_V2 = { model_calls: 7, web_searches: 5, pages: 9, page_requests: 12, page_bytes: 6291456, work_seconds: 480, crawl_seconds: 60 };
|
|
7
|
+
const LIMITS_V3 = { ...LIMITS_V2, page_requests: 36 };
|
|
8
|
+
const NOTICE_TEXT = {
|
|
9
|
+
bounded_sources: 'This run inspected bounded saved source windows. It does not establish whole-question completeness.',
|
|
10
|
+
evidence_omitted: 'Some discovery, acquisition or source windows were omitted or unavailable. Read the saved gaps and evidence limits before using this answer.',
|
|
11
|
+
repair_input_bound: 'The completion pass could not use additional material within the input allowance. Earlier validated findings were retained.',
|
|
12
|
+
unassessed: 'Requested-detail coverage could not be assessed. No completion pass was inferred from that missing assessment.',
|
|
13
|
+
};
|
|
14
|
+
const REASONS = ['admitted', 'completion_reviewed', 'no_eligible_repair', 'no_identified_gap', 'unassessed', 'repair_input_bound', 'no_new_admitted_source', 'failed', 'unknown', 'cancelled'];
|
|
15
|
+
const PHASES = ['completion_discover', 'synthesize', 'completion_search', 'completion_synthesize'];
|
|
16
|
+
const PHASES_V2 = [...PHASES, 'presentation_draft', 'presentation_review'];
|
|
17
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
const integer = (value, max) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
19
|
+
const keys = (value, required, optional = []) => required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => required.includes(key) || optional.includes(key));
|
|
20
|
+
/** Missing or malformed optional metadata yields no notices; saved run fields stay untouched. */
|
|
21
|
+
export function readResearchCompletion(run) {
|
|
22
|
+
const empty = { completion: null, notices: [] };
|
|
23
|
+
try {
|
|
24
|
+
if (!object(run?.usage))
|
|
25
|
+
return empty;
|
|
26
|
+
const value = run.usage.completion;
|
|
27
|
+
if (!object(value) || !keys(value, ['version', 'policy', 'policy_sha256', 'limits', 'reason', 'validated_passes', 'whole_request_completeness', 'notices'], ['timing_ms', 'completed_usage_lower_bound', 'acquisition']) || value.whole_request_completeness !== 'not_certified' || !REASONS.includes(value.reason) || !integer(value.validated_passes, 2))
|
|
28
|
+
return empty;
|
|
29
|
+
const v3 = value.version === 3, v2 = value.version === 2 || v3;
|
|
30
|
+
if (!(v3 ? value.policy === 'completion_v3' && value.policy_sha256 === POLICY_V3_SHA256 : v2 ? value.policy === 'completion_v2' && value.policy_sha256 === POLICY_V2_SHA256 : value.version === 1 && value.policy === 'completion_v1' && value.policy_sha256 === POLICY_SHA256))
|
|
31
|
+
return empty;
|
|
32
|
+
const limits = v3 ? LIMITS_V3 : v2 ? LIMITS_V2 : LIMITS, phases = v2 ? PHASES_V2 : PHASES;
|
|
33
|
+
if (!object(value.limits) || !keys(value.limits, Object.keys(limits)) || Object.entries(limits).some(([key, limit]) => value.limits[key] !== limit))
|
|
34
|
+
return empty;
|
|
35
|
+
if (!Array.isArray(value.notices) || value.notices.length > 4 || new Set(value.notices).size !== value.notices.length || !value.notices.every(code => typeof code === 'string' && Object.hasOwn(NOTICE_TEXT, code)))
|
|
36
|
+
return empty;
|
|
37
|
+
if (Object.hasOwn(value, 'timing_ms')) {
|
|
38
|
+
const timing = value.timing_ms;
|
|
39
|
+
if (!object(timing) || !keys(timing, ['total', 'phases']) || !integer(timing.total, v2 ? 600000 : 360000) || !Array.isArray(timing.phases) || timing.phases.length > (v2 ? 6 : 4) || !timing.phases.every(phase => object(phase) && keys(phase, ['phase', 'elapsed_ms']) && phases.includes(phase.phase) && integer(phase.elapsed_ms, v2 ? 300000 : 210000)))
|
|
40
|
+
return empty;
|
|
41
|
+
}
|
|
42
|
+
if (Object.hasOwn(value, 'acquisition')) {
|
|
43
|
+
const acquisition = value.acquisition;
|
|
44
|
+
if (!v3 || value.reason === 'admitted' || !object(acquisition) || !keys(acquisition, ['version', 'stages']) || acquisition.version !== 1 || !Array.isArray(acquisition.stages) || acquisition.stages.length < 1 || acquisition.stages.length > 2)
|
|
45
|
+
return empty;
|
|
46
|
+
let requests = 0, pages = 0, bytes = 0, elapsed = 0;
|
|
47
|
+
for (const [index, row] of acquisition.stages.entries()) {
|
|
48
|
+
if (!object(row) || !keys(row, ['stage', 'requests', 'pages', 'bytes', 'elapsed_ms']) || row.stage !== (index === 0 ? 'initial' : 'repair') || !integer(row.requests, index === 0 ? 24 : 36) || !integer(row.pages, index === 0 ? 6 : 3) || !integer(row.bytes, 6291456) || !integer(row.elapsed_ms, 360000))
|
|
49
|
+
return empty;
|
|
50
|
+
requests += Number(row.requests);
|
|
51
|
+
pages += Number(row.pages);
|
|
52
|
+
bytes += Number(row.bytes);
|
|
53
|
+
elapsed += Number(row.elapsed_ms);
|
|
54
|
+
}
|
|
55
|
+
if (requests > 36 || pages > 9 || bytes > 6291456 || elapsed > 360000 || requests !== run.usage.page_requests || pages !== run.usage.pages_fetched || bytes !== run.usage.page_bytes)
|
|
56
|
+
return empty;
|
|
57
|
+
if (object(value.timing_ms) && elapsed > Number(value.timing_ms.total))
|
|
58
|
+
return empty;
|
|
59
|
+
}
|
|
60
|
+
if (Object.hasOwn(value, 'completed_usage_lower_bound')) {
|
|
61
|
+
const usage = value.completed_usage_lower_bound;
|
|
62
|
+
if (!object(usage) || !keys(usage, ['model_calls', 'web_searches', 'input_tokens', 'output_tokens']) || !integer(usage.model_calls, limits.model_calls) || !integer(usage.web_searches, 5) || !integer(usage.input_tokens, 1000000000) || !integer(usage.output_tokens, 1000000000))
|
|
63
|
+
return empty;
|
|
64
|
+
}
|
|
65
|
+
if (value.reason === 'admitted' && (value.validated_passes !== 0 || value.notices.length !== 0 || Object.hasOwn(value, 'timing_ms') || Object.hasOwn(value, 'completed_usage_lower_bound')))
|
|
66
|
+
return empty;
|
|
67
|
+
// Detach the returned view from the saved receipt; only fixed local text is displayed.
|
|
68
|
+
const completion = structuredClone(value);
|
|
69
|
+
return { completion, notices: completion.notices.map(code => ({ code, text: NOTICE_TEXT[code] })) };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return empty;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const PRESENTATION_STATUSES = ['reviewed', 'partial', 'refused', 'skipped_no_eligible', 'skipped_bound', 'skipped_capacity', 'unavailable', 'unknown', 'deadline', 'interrupted'];
|
|
76
|
+
/** Optional usage only: its digest reports provenance, not a client recomputation of canonical content. */
|
|
77
|
+
export function readResearchPresentation(run) {
|
|
78
|
+
try {
|
|
79
|
+
if (!object(run?.usage))
|
|
80
|
+
return null;
|
|
81
|
+
const value = run.usage.presentation;
|
|
82
|
+
if (!object(value) || !keys(value, ['version', 'status', 'canonical_sha256', 'calls_reserved', 'calls_completed', 'calls_uncertain', 'known_input_tokens', 'known_output_tokens', 'usage_is_lower_bound', 'elapsed_ms']) || value.version !== 1 || !PRESENTATION_STATUSES.includes(value.status) || typeof value.canonical_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.canonical_sha256))
|
|
83
|
+
return null;
|
|
84
|
+
if (!integer(value.calls_reserved, 2) || !integer(value.calls_completed, 2) || !integer(value.calls_uncertain, 2) || Number(value.calls_completed) + Number(value.calls_uncertain) > Number(value.calls_reserved) || !integer(value.known_input_tokens, 1000000000) || !integer(value.known_output_tokens, 12000) || value.usage_is_lower_bound !== (Number(value.calls_uncertain) > 0) || !integer(value.elapsed_ms, 180000))
|
|
85
|
+
return null;
|
|
86
|
+
if (value.calls_completed === 0 && value.known_input_tokens !== 0 || Number(value.known_output_tokens) > Number(value.calls_completed) * 6000)
|
|
87
|
+
return null;
|
|
88
|
+
return structuredClone(value);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=researchCompletion.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {displayLine,displayText} from './display.mjs';
|
|
2
|
+
const integer=(value,min=0,max=2_000_000)=>Number.isSafeInteger(value)&&value>=min&&value<=max;
|
|
3
|
+
const text=(value,max)=>typeof value==='string'&&value.length<=max;
|
|
4
|
+
const hash=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
|
|
5
|
+
const invalid=()=>new Error('Server returned invalid or out-of-scope retained event evidence; no source text was emitted.');
|
|
6
|
+
function window(value,offset,comparison){
|
|
7
|
+
if(value===null){if(offset!==undefined)throw invalid();return 0;}
|
|
8
|
+
if(!value||!text(value.version_id,200)||!value.version_id||!hash(value.version_sha256)||!hash(value.body_sha256)||!text(value.title,8192)||!text(value.url,8192)||!(value.final_url===null||text(value.final_url,65536))||!(value.extraction_version===null||text(value.extraction_version,65536))||!text(value.version_observed_at,200)||!value.version_observed_at||!text(value.text,64000)||!integer(value.full_length)||!integer(value.start_char,0,value.full_length)||!integer(value.end_char,value.start_char,value.full_length))throw invalid();
|
|
9
|
+
const points=Array.from(value.text);
|
|
10
|
+
if(points.length!==value.end_char-value.start_char||points.some(point=>point.length===1&&/[\uD800-\uDFFF]/.test(point))||value.next_start_char!==(value.end_char<value.full_length?value.end_char:null)||value.omitted_before!==(value.start_char>0)||value.omitted_after!==(value.end_char<value.full_length))throw invalid();
|
|
11
|
+
if(offset!==undefined){if(value.window_origin!=='explicit_offset'||value.start_char!==offset)throw invalid();}
|
|
12
|
+
else if(value.window_origin!==(comparison==='contiguous_region'?'changed_region':'page_start')||(value.window_origin==='page_start'&&value.start_char!==0))throw invalid();
|
|
13
|
+
if(comparison==='contiguous_region'){
|
|
14
|
+
if(!integer(value.changed_start_char,0,value.full_length)||!integer(value.changed_end_char,value.changed_start_char,value.full_length))throw invalid();
|
|
15
|
+
}else if(value.changed_start_char!==null||value.changed_end_char!==null)throw invalid();
|
|
16
|
+
return Buffer.byteLength(value.text,'utf8');
|
|
17
|
+
}
|
|
18
|
+
export function validateEventEvidence(value,request){
|
|
19
|
+
if(!value||value.id!==request.eventId||value.collection_id!==request.id||!text(value.url,8192)||!['baseline','changed','unavailable','recovered'].includes(value.kind)||!text(value.observed_at,200)||!value.observed_at||!(value.previous_observed_at===null||text(value.previous_observed_at,200))||typeof value.gap!=='boolean'||!['contiguous_region','equal_body','single_observation','no_observation'].includes(value.comparison)||value.budget!==request.budget||!integer(value.text_bytes,0,request.budget))throw invalid();
|
|
20
|
+
const count=Number(value.before!==null)+Number(value.after!==null);
|
|
21
|
+
if(count!==(value.comparison==='no_observation'?0:value.comparison==='single_observation'?1:2))throw invalid();
|
|
22
|
+
const total=window(value.before,request.beforeStart,value.comparison)+window(value.after,request.afterStart,value.comparison);
|
|
23
|
+
if(total!==value.text_bytes||total>request.budget||(value.comparison==='equal_body'&&value.before?.body_sha256!==value.after?.body_sha256)||(value.comparison==='contiguous_region'&&value.before?.body_sha256===value.after?.body_sha256))throw invalid();
|
|
24
|
+
// Full observation hashes are metadata; a partial window cannot verify the full retained body.
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
export function renderEventEvidence(value,stdout,stderr){
|
|
28
|
+
stdout(`Event: ${displayLine(value.id)}\nCollection: ${displayLine(value.collection_id)}\nKind: ${displayLine(value.kind)}\nObserved: ${displayLine(value.observed_at)}\nPrevious observation: ${value.previous_observed_at===null?'none':displayLine(value.previous_observed_at)}\nURL: ${displayLine(value.url)}\nComparison: ${displayLine(value.comparison)}\n`);
|
|
29
|
+
for(const side of ['before','after']){
|
|
30
|
+
const item=value[side],label=side==='before'?'Previous observation':'This observation';
|
|
31
|
+
if(item===null){stdout(`\n${label}: no retained observation for this side.\n`);continue;}
|
|
32
|
+
stdout(`\n${label}: ${displayLine(item.title)}\nVersion: ${displayLine(item.version_id)}\nVersion observed: ${displayLine(item.version_observed_at)}\nURL: ${displayLine(item.url)}\nCodepoints: [${item.start_char}, ${item.end_char}) of ${item.full_length}\n${displayText(item.text)}\n`);
|
|
33
|
+
if(item.omitted_before)stderr(`${label}: earlier source text is omitted; use --${side}-start 0 to read from the beginning.\n`);
|
|
34
|
+
if(item.next_start_char!==null)stderr(`${label}: later source text is omitted; continue explicitly with --${side}-start ${item.next_start_char}.\n`);
|
|
35
|
+
}
|
|
36
|
+
if(value.gap)stderr('Unsuccessful checks separate these observations; the exact change time is unknown.\n');
|
|
37
|
+
if(value.comparison==='contiguous_region')stderr('This enclosing changed region can contain unchanged or moved text; it is not a minimal diff.\n');
|
|
38
|
+
stderr(`${value.text_bytes} source-text UTF-8 bytes returned within the ${value.budget}-byte budget. Times describe observations, not authored modifications. Baselines are not new business events; unavailable checks do not prove removal. No crawl or search was started.\n`);
|
|
39
|
+
}
|
package/src/research.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import {allowedOptions,decimal,identifier,withInterrupt} from './jobs.mjs';
|
|
3
|
+
import {displayLine,displayText} from './display.mjs';
|
|
4
|
+
import {coverageWarnings} from './context.mjs';
|
|
5
|
+
import {validateEventEvidence,renderEventEvidence} from './research-event.mjs';
|
|
6
|
+
import {agentCommand} from './research-agent.mjs';
|
|
7
|
+
export const RESEARCH_HELP=`OpenCrawl · collect and search public research
|
|
8
|
+
|
|
9
|
+
opencrawl research agent create --file brief.json --request-key RANDOM_KEY [--wait]
|
|
10
|
+
opencrawl research agent list [--limit 25] [--cursor OPAQUE_CURSOR]
|
|
11
|
+
opencrawl research agent show AGENT_ID
|
|
12
|
+
opencrawl research agent run AGENT_ID --revision N --request-key RANDOM_KEY [--wait]
|
|
13
|
+
opencrawl research agent pause|resume AGENT_ID --revision N
|
|
14
|
+
opencrawl research agent receipt --operation create|run:AGENT_ID --request-key RANDOM_KEY
|
|
15
|
+
opencrawl research agent schedule AGENT_ID [show|daily|pause|resume] [--revision N]
|
|
16
|
+
opencrawl research agent repeats AGENT_ID RUN_ID
|
|
17
|
+
opencrawl research agent source AGENT_ID RUN_ID SOURCE_ID
|
|
18
|
+
opencrawl research agent feedback AGENT_ID RUN_ID FINDING_ID [show|set|delete] [--revision N] [--rating useful|irrelevant|incorrect|outdated] [--note TEXT]
|
|
19
|
+
opencrawl research agent runs AGENT_ID [--limit N] [--cursor CURSOR]
|
|
20
|
+
opencrawl research agent-run AGENT_ID RUN_ID [--wait] [--timeout 300]
|
|
21
|
+
|
|
22
|
+
opencrawl research create --file collection.json --request-key RANDOM_KEY [--wait] [--timeout 300]
|
|
23
|
+
opencrawl research list [--limit 20] [--cursor OPAQUE_CURSOR]
|
|
24
|
+
opencrawl research show COLLECTION_ID
|
|
25
|
+
opencrawl research refresh COLLECTION_ID --request-key RANDOM_KEY [--revision N] [--wait] [--timeout 300]
|
|
26
|
+
opencrawl research pause|resume COLLECTION_ID --revision N
|
|
27
|
+
opencrawl research configure COLLECTION_ID --file settings.json --request-key RANDOM_KEY
|
|
28
|
+
opencrawl research job JOB_ID [--wait] [--timeout 300]
|
|
29
|
+
opencrawl research search "question" --collection COLLECTION_ID [--limit 10] [--budget 6000]
|
|
30
|
+
opencrawl research events COLLECTION_ID [--limit 10] [--cursor OPAQUE_CURSOR]
|
|
31
|
+
opencrawl research event COLLECTION_ID EVENT_ID [--budget 16000] [--before-start N] [--after-start N]
|
|
32
|
+
|
|
33
|
+
Use --json for exact receipts. Create/refresh/configure never retry automatically: retain
|
|
34
|
+
both the original JSON descriptor and a random 16–128 character request key.
|
|
35
|
+
After a lost response, repeat the exact command with the same key to recover
|
|
36
|
+
its original receipt. Changing the descriptor with that key fails closed.
|
|
37
|
+
--wait follows the accepted job only; Ctrl+C never cancels or resubmits it.
|
|
38
|
+
Completed exits 0, partial exits 2, failed/cancelled/unconfirmed waits exit 1.
|
|
39
|
+
Search reads the current index in one explicit collection; it never refreshes.
|
|
40
|
+
Event reads retained source text only: budget is total UTF-8 source bytes
|
|
41
|
+
(256–32000), offsets are codepoints. Continuation is always explicit.
|
|
42
|
+
Configure requires exactly expected_revision, page_limit (1–5000),
|
|
43
|
+
schedule_minutes (0 for manual, or 5–10080), and include_media (boolean).
|
|
44
|
+
It preserves source scope/history and starts no immediate job. Positive cadence
|
|
45
|
+
permits recurring work. Unfinished crawl/media work blocks changed settings.
|
|
46
|
+
Baseline observations are not new events. Failed checks do not prove removal.
|
|
47
|
+
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
48
|
+
`;
|
|
49
|
+
const ROOT='/api/research';
|
|
50
|
+
const ACTIVE=new Set(['queued','running']);
|
|
51
|
+
const STATES=new Set(['queued','running','completed','partial','failed','cancelled']);
|
|
52
|
+
const finite=(n,min=0,max=Number.MAX_SAFE_INTEGER)=>Number.isSafeInteger(n)&&n>=min&&n<=max;
|
|
53
|
+
const text=(value,max=200)=>typeof value==='string'&&value.length>0&&value.length<=max;
|
|
54
|
+
export function researchKey(value){if(typeof value!=='string'||!(/^[A-Za-z0-9_-]{16,128}$/).test(value))throw new Error('Supply --request-key with a saved random 16–128 character key; reuse it only for this exact create, refresh or configure.');return value;}
|
|
55
|
+
function pageQuery(options,defaultLimit=20,max=100){const limit=decimal(options.limit,'limit',1,max,defaultLimit),query=new URLSearchParams({limit});if(options.cursor!==undefined){if(!text(options.cursor,512))throw new Error('--cursor must be the exact bounded cursor returned by the server.');query.set('cursor',options.cursor)}return {limit,query};}
|
|
56
|
+
export function researchRequest(rest,options){
|
|
57
|
+
const [command,id,...extra]=rest;
|
|
58
|
+
const allowed={create:['file','request-key','wait','timeout'],configure:['file','request-key'],list:['limit','cursor'],show:[],refresh:['request-key','revision','wait','timeout'],pause:['revision'],resume:['revision'],job:['wait','timeout'],search:['collection','limit','budget'],events:['limit','cursor'],event:['budget','before-start','after-start']};
|
|
59
|
+
if(!Object.hasOwn(allowed,command))throw new Error('Choose a research command from opencrawl research --help.');
|
|
60
|
+
allowedOptions(options,allowed[command],'research');
|
|
61
|
+
if(command==='event'?extra.length!==1:command!=='search'&&(extra.length||['create','list'].includes(command)&&id!==undefined))throw new Error('Unexpected research argument.');
|
|
62
|
+
if(options.timeout!==undefined&&!options.wait)throw new Error('--timeout requires --wait.');
|
|
63
|
+
const timeout=decimal(options.timeout,'timeout',1,3600,300);
|
|
64
|
+
if(command==='create'){if(!text(options.file,4096))throw new Error('Provide --file with the collection JSON descriptor.');return {command,route:ROOT+'/collections',method:'POST',file:options.file,key:researchKey(options['request-key']),wait:!!options.wait,timeout};}
|
|
65
|
+
if(command==='list'){const {limit,query}=pageQuery(options);return {command,limit,route:ROOT+'/collections?'+query};}
|
|
66
|
+
if(command==='search'){const query=rest.slice(1).join(' ').trim();if(!query||Array.from(query).length>1000)throw new Error('Provide a nonempty question of at most 1,000 characters.');const collection=identifier(options.collection,'collection');return {command,collection,route:ROOT+'/search',method:'POST',body:{query,collection_ids:[collection],freshness:'indexed',limit:decimal(options.limit,'limit',1,50,10),budget:decimal(options.budget,'budget',0,32000,6000)}};}
|
|
67
|
+
identifier(id,command==='job'?'job':'collection');
|
|
68
|
+
const route=ROOT+(command==='job'?'/jobs/':'/collections/')+encodeURIComponent(id);
|
|
69
|
+
if(command==='event'){
|
|
70
|
+
const eventId=identifier(extra[0],'event'),budget=decimal(options.budget,'budget',256,32000,16000),query=new URLSearchParams({budget});
|
|
71
|
+
const beforeStart=options['before-start']===undefined?undefined:decimal(options['before-start'],'before-start',0,2_000_000);
|
|
72
|
+
const afterStart=options['after-start']===undefined?undefined:decimal(options['after-start'],'after-start',0,2_000_000);
|
|
73
|
+
if(beforeStart!==undefined)query.set('before_start',beforeStart);if(afterStart!==undefined)query.set('after_start',afterStart);
|
|
74
|
+
return {command,id,eventId,budget,beforeStart,afterStart,route:route+'/events/'+encodeURIComponent(eventId)+'?'+query};
|
|
75
|
+
}
|
|
76
|
+
if(command==='configure'){if(!text(options.file,4096))throw new Error('Provide --file with all four reviewed settings.');return {command,id,route:route+'/configure',method:'POST',file:options.file,key:researchKey(options['request-key'])};}
|
|
77
|
+
if(command==='events'){const {limit,query}=pageQuery(options,10,25);return {command,id,limit,route:route+'/events?'+query};}
|
|
78
|
+
if(command==='refresh')return {command,id,route:route+'/refresh',method:'POST',body:options.revision===undefined?{}:{expected_revision:decimal(options.revision,'revision',1,Number.MAX_SAFE_INTEGER)},key:researchKey(options['request-key']),wait:!!options.wait,timeout};
|
|
79
|
+
if(command==='pause'||command==='resume')return {command,id,route,method:'PATCH',body:{status:command==='pause'?'paused':'active',expected_revision:decimal(options.revision,'revision',1,Number.MAX_SAFE_INTEGER)}};
|
|
80
|
+
return {command,id,route,wait:!!options.wait,timeout};
|
|
81
|
+
}
|
|
82
|
+
export function validateResearchJob(value,id=value?.id){if(!value||!text(value.id)||value.id!==id||!text(value.collection_id)||!STATES.has(value.status)||!['discovery','fetching','finished'].includes(value.phase)||!value.progress||!['discovered','fetched','indexed','failed','bytes'].every(key=>finite(value.progress[key])))throw new Error('Server returned an invalid research job receipt. No completion was assumed.');return value;}
|
|
83
|
+
export function validateResearchCollection(value,id=value?.id){if(!value||!text(value.id)||value.id!==id||!text(value.source_id)||!text(value.name,200)||!['active','paused'].includes(value.status)||!finite(value.revision,1)||!Array.isArray(value.seed_urls)||!Array.isArray(value.allowed_origins)||!value.stats||!['discovered','indexed','failed','bytes'].every(key=>finite(value.stats[key])))throw new Error('Server returned an invalid research collection.');return value;}
|
|
84
|
+
function validatePage(value,limit){if(!value||!Array.isArray(value.items)||value.items.length>limit||!(value.next_cursor===null||text(value.next_cursor,512)))throw new Error('Server returned an invalid research page.');return value;}
|
|
85
|
+
function validateEvents(value,request){validatePage(value,request.limit);for(const item of value.items){if(!item||!text(item.id)||item.collection_id!==request.id||!['baseline','changed','unavailable','recovered'].includes(item.kind)||typeof item.url!=='string'||typeof item.observed_at!=='string'||!item.change_summary||typeof item.gap!=='boolean')throw new Error('Server returned invalid or out-of-scope research observations.');for(const side of ['before','after'])if(item[side]!==null&&(!item[side]||typeof item[side].excerpt!=='string'||Array.from(item[side].excerpt).length>4000))throw new Error('Server returned an oversized observation excerpt.');}return value;}
|
|
86
|
+
function validateSearch(value,request){const expected=request.body.query.split(/[\s\u0085\u001c-\u001f]+/u).filter(Boolean).join(' ');if(!value||value.query!==expected||!Array.isArray(value.results)||value.results.length>request.body.limit||typeof value.context!=='string'||Buffer.byteLength(value.context)>request.body.budget||!Array.isArray(value.evidence?.citations)||Boolean(value.context)!==Boolean(value.evidence.citations.length)||!value.freshness||!['indexed','empty'].includes(value.freshness.status)||!Array.isArray(value.freshness.refresh_jobs)||value.freshness.refresh_jobs.length)throw new Error('Server returned an invalid indexed research response; no context was emitted.');for(const result of value.results)if(!text(result.document_id)||!text(result.title,2000)||typeof result.source_id!=='string'||result.research_observation&&result.research_observation.collection_id!==request.collection)throw new Error('Server returned invalid or out-of-scope research citations.');for(const citation of value.evidence.citations)if(!value.results.some(result=>result.document_id===citation.document_id))throw new Error('Server returned a context citation outside the search results.');return value;}
|
|
87
|
+
export function researchFailure(error,request){const status=Number.isInteger(error?.status)?error.status:undefined;const fixed={401:'Sign in with a valid key for the intended workspace.',403:'This account cannot perform the requested research operation.',404:'This collection, event or job is unavailable. Check the selected workspace and exact identifier.',409:'The collection or request changed, or work remains unfinished. Review its current revision and jobs; preserve the original request key and descriptor before explicitly retrying.',422:'The research selection or descriptor is invalid. Check the approved origins, limits and explicit identifiers.',429:'OpenCrawl is busy or a workspace limit was reached. Check the collection before retrying.'};const message=fixed[status]||'The request outcome could not be confirmed. Recover create/refresh/configure with the same request key and exact descriptor; never start a duplicate blindly.';return {code:status?`http_${status}`:'request_unconfirmed',message,...(status?{status}:{}),...(request.key?{request_key:request.key}:{}),...(request.id?{id:request.id}:{})};}
|
|
88
|
+
export function validateResearchSettings(value){
|
|
89
|
+
const fields=['expected_revision','page_limit','schedule_minutes','include_media'];
|
|
90
|
+
if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==fields.length||fields.some(key=>!Object.hasOwn(value,key))||!finite(value.expected_revision,1)||!finite(value.page_limit,1,5000)||!(value.schedule_minutes===0||finite(value.schedule_minutes,5,10080))||typeof value.include_media!=='boolean')throw new Error('Settings JSON must contain exactly expected_revision (positive integer), page_limit (1–5000), schedule_minutes (0 or 5–10080), and include_media (boolean).');
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
async function settingsDescriptor(file){
|
|
94
|
+
const handle=await fs.open(file,'r');
|
|
95
|
+
try{
|
|
96
|
+
const size=(await handle.stat()).size;if(!finite(size,1,256*1024))throw new Error('Settings JSON must be between 1 byte and 256 KiB.');
|
|
97
|
+
const bytes=Buffer.alloc(size+1),{bytesRead}=await handle.read(bytes,0,size+1,0);if(bytesRead!==size)throw new Error('The settings descriptor changed while being read.');
|
|
98
|
+
let value;try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes.subarray(0,size)))}catch{throw new Error('Invalid settings JSON. Supply the original valid UTF-8 settings descriptor.');}
|
|
99
|
+
return validateResearchSettings(value);
|
|
100
|
+
}finally{await handle.close()}
|
|
101
|
+
}
|
|
102
|
+
export function validateConfigureReceipt(value,request){
|
|
103
|
+
const collection=validateResearchCollection(value?.collection,request.id),settings=request.body;
|
|
104
|
+
const utc=value.next_scheduled_at===null||typeof value.next_scheduled_at==='string'&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value.next_scheduled_at)&&Number.isFinite(Date.parse(value.next_scheduled_at))&&new Date(value.next_scheduled_at).toISOString().slice(0,19)===value.next_scheduled_at.slice(0,19);
|
|
105
|
+
if(Object.keys(value).some(key=>!['collection','next_scheduled_at','replayed'].includes(key))||typeof value.replayed!=='boolean'||!utc||!['page_limit','schedule_minutes','include_media'].every(key=>collection[key]===settings[key])||![settings.expected_revision,settings.expected_revision+1].includes(collection.revision)||!collection.seed_urls.length||collection.seed_urls.length>50||!collection.allowed_origins.length||collection.allowed_origins.length>20||![...collection.seed_urls,...collection.allowed_origins].every(url=>text(url,2048))||((collection.status==='paused'||settings.schedule_minutes===0)&&value.next_scheduled_at!==null)||(collection.revision!==settings.expected_revision&&collection.status==='active'&&settings.schedule_minutes>0&&value.next_scheduled_at===null))throw new Error('The settings receipt could not be verified. Retain the original request key and descriptor.');
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
async function descriptor(file){const handle=await fs.open(file,'r');try{const size=(await handle.stat()).size;if(!finite(size,1,256*1024))throw new Error('Collection JSON must be between 1 byte and 256 KiB.');const bytes=Buffer.alloc(size+1);const {bytesRead}=await handle.read(bytes,0,size+1,0);if(bytesRead!==size)throw new Error('The collection descriptor changed while being read.');let value;try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes.subarray(0,size)))}catch{throw new Error('Invalid collection JSON. Supply a valid UTF-8 collection descriptor.');}const allowed=['name','brief','seed_urls','allowed_origins','sitemap_urls','page_limit','schedule_minutes','include_media'];if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).some(key=>!allowed.includes(key))||!text(value.name,200)||!Array.isArray(value.seed_urls)||!value.seed_urls.length||value.seed_urls.length>50)throw new Error('Collection JSON must contain name and 1–50 seed_urls, with only documented collection settings.');return value;}finally{await handle.close()}}
|
|
109
|
+
export async function waitResearch(client,initial,{timeout=300,signal,now=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}){let job=validateResearchJob(initial);const deadline=now()+timeout*1000;while(ACTIVE.has(job.status)){if(signal?.aborted)return {job,wait:{outcome:'interrupted'}};if(now()>=deadline)return {job,wait:{outcome:'timeout'}};try{job=validateResearchJob(await client.request(ROOT+'/jobs/'+encodeURIComponent(job.id),{timeout:Math.max(1,Math.min(30000,Math.ceil(deadline-now()))),signal,maxResponseBytes:1024*1024}),job.id)}catch(error){return {job,wait:{outcome:signal?.aborted?'interrupted':now()>=deadline?'timeout':'unavailable',error:researchFailure(error,{id:job.id})}}}if(!ACTIVE.has(job.status))break;const next=Math.min(deadline,now()+2000);while(!signal?.aborted&&now()<next)await sleep(Math.min(100,next-now()));}return {job,wait:{outcome:job.status}};}
|
|
110
|
+
const renderJob=value=>`${displayLine(value.id)}\t${displayLine(value.status)}\t${value.progress.indexed} indexed, ${value.progress.failed} unsuccessful, ${value.progress.discovered} discovered\n`;
|
|
111
|
+
export async function researchCommand(rest,options,client,{stdout=value=>process.stdout.write(value),stderr=value=>process.stderr.write(value)}={}){
|
|
112
|
+
if(['agent','agent-run'].includes(rest[0]))return agentCommand(rest,options,client,{stdout,stderr});
|
|
113
|
+
const request=researchRequest(rest,options);if(request.file)request.body=await (request.command==='configure'?settingsDescriptor(request.file):descriptor(request.file));
|
|
114
|
+
let value;try{value=await client.request(request.route,{method:request.method,body:request.body,idempotencyKey:request.key,maxResponseBytes:4*1024*1024});}catch(error){const failure=researchFailure(error,request);if(options.json)stdout(JSON.stringify({error:failure},null,2)+'\n');else stderr(failure.message+(request.key?`\nOriginal request key: ${request.key}`:'')+'\n');return 1;}
|
|
115
|
+
if(request.command==='event'){validateEventEvidence(value,request);if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else renderEventEvidence(value,stdout,stderr);return 0;}
|
|
116
|
+
if(request.command==='configure'){
|
|
117
|
+
try{validateConfigureReceipt(value,request)}catch(error){const failure=researchFailure(error,request);if(options.json)stdout(JSON.stringify({error:failure},null,2)+'\n');else stderr(failure.message+'\nOriginal request key: '+request.key+'\n');return 1;}
|
|
118
|
+
if(options.json)stdout(JSON.stringify(value,null,2)+'\n');
|
|
119
|
+
else{stdout(`Collection: ${displayLine(value.collection.id)}\nRevision: ${value.collection.revision}\nPage limit: ${value.collection.page_limit}\nSchedule: ${value.collection.schedule_minutes===0?'manual':value.collection.schedule_minutes+' minutes'}\nMedia: ${value.collection.include_media?'enabled':'disabled'}\nNext scheduled check: ${value.next_scheduled_at??'none'}\n`);stderr('No immediate crawl was started. Source scope and evidence history are preserved.\n'+(value.replayed?'Recovered the original receipt; read current state before another change.\n':'')+'Original request key: '+request.key+'\n');}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if(request.command==='list'){validatePage(value,request.limit);value.items.forEach(item=>validateResearchCollection(item));if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{stdout(value.items.map(item=>`${displayLine(item.id)}\tr${item.revision}\t${item.stats.indexed} indexed\t${displayLine(item.name)}\n`).join('')||'No research collections on this page.\n');if(value.next_cursor)stderr('Next cursor: '+displayLine(value.next_cursor)+'\n');}return 0;}
|
|
123
|
+
if(request.command==='events'){validateEvents(value,request);if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{for(const event of value.items){stdout(`${displayLine(event.id)}\t${displayLine(event.kind)}\t${displayLine(event.observed_at)}\t${displayLine(event.url)}\n`);for(const side of ['before','after'])if(event[side]){stdout(`${side==='before'?'Previous observation':'This observation'}: ${displayLine(event[side].title)}\n${displayText(event[side].excerpt)}\n`);if(event[side].excerpt_truncated)stderr(`${side==='before'?'Previous':'Current'} observation excerpt is shortened.\n`);}if(event.gap)stderr('Unsuccessful checks separate these observations; the exact change time is unknown.\n');if(event.change_summary.truncated)stderr('Observation excerpts or differences are shortened.\n');}if(!value.items.length)stdout('No retained observations on this page.\n');if(value.next_cursor)stderr('Next cursor: '+displayLine(value.next_cursor)+'\n');stderr('Baselines are not new business events. Unavailable checks do not prove removal. Times are observations, not authored modifications.\n');}return 0;}
|
|
124
|
+
if(request.command==='search'){validateSearch(value,request);if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{stdout(value.context);stderr(`${value.evidence.citations.length} context citations from ${value.results.length} matching results in the explicitly selected collection.\n`);if(!value.results.length)stderr('No matching indexed evidence; no broader search or refresh was attempted.\n');for(const warning of coverageWarnings(value))stderr(displayLine(warning)+'\n');}return 0;}
|
|
125
|
+
if(['show','pause','resume'].includes(request.command)){validateResearchCollection(value,request.id);stdout(options.json?JSON.stringify(value,null,2)+'\n':`${displayLine(value.name)}\nCollection: ${displayLine(value.id)}\nRevision: ${value.revision}\nStatus: ${value.status}\nIndexed pages: ${value.stats.indexed}\nApproved origins: ${value.allowed_origins.map(displayLine).join(', ')}\n`);return 0;}
|
|
126
|
+
let collection,job;
|
|
127
|
+
if(request.command==='create'){collection=validateResearchCollection(value.collection);job=validateResearchJob(value.job);if(job.collection_id!==collection.id)throw new Error('Collection receipt and job do not match.');}
|
|
128
|
+
else if(request.command==='refresh'){job=validateResearchJob(value.job);if(job.collection_id!==request.id)throw new Error('Refresh receipt belongs to another collection.');}
|
|
129
|
+
else job=validateResearchJob(value,request.id);
|
|
130
|
+
let receipt=request.command==='job'?{job}:{...value};if(request.wait){const waited=await withInterrupt(signal=>waitResearch(client,job,{timeout:request.timeout,signal}));job=waited.job;receipt={...receipt,...waited};}
|
|
131
|
+
if(options.json)stdout(JSON.stringify(receipt,null,2)+'\n');else{if(collection)stdout('Collection: '+displayLine(collection.id)+'\n');stdout(renderJob(job));if(request.key)stderr('Original request key: '+request.key+'\n');if(receipt.wait&&!['completed','partial'].includes(receipt.wait.outcome))stderr(`Wait outcome: ${receipt.wait.outcome}. Follow the same job with research job ${displayLine(job.id)} --wait.\n`);stderr('Job counts describe bounded configured coverage, not the entire website. Media discovery is not successful parsing.\n');}
|
|
132
|
+
return request.wait?receipt.wait.outcome==='completed'?0:receipt.wait.outcome==='partial'?2:1:request.command==='job'&&!ACTIVE.has(job.status)?job.status==='completed'?0:job.status==='partial'?2:1:0;
|
|
133
|
+
}
|
package/src/sessions.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { displayText, displayLine as line } from './display.mjs';
|
|
2
|
+
/** Explicit, version-bound reading of imported conversations. No local history state. */
|
|
3
|
+
export const SESSIONS_HELP = `OpenCrawl · read imported conversations
|
|
4
|
+
|
|
5
|
+
opencrawl sessions list [--provider P] [--limit 20] [--offset 0]
|
|
6
|
+
opencrawl sessions read ID --branch selected|all [--cursor TOKEN] [--limit 30]
|
|
7
|
+
opencrawl sessions export ID --revision TOKEN --branch selected|all
|
|
8
|
+
--selection tN:START:END [--selection tN:START:END ...] --budget BYTES
|
|
9
|
+
|
|
10
|
+
Read prints the current revision and exact selectors for returned fragments.
|
|
11
|
+
Copy selectors from read; START is inclusive and END exclusive, as returned by
|
|
12
|
+
OpenCrawl. They are document character positions, not bytes or turn numbers.
|
|
13
|
+
Each export requires an explicit revision, branch, selection and byte budget.
|
|
14
|
+
It exports up to 20 selected ranges, without fetching unseen pages or guessing
|
|
15
|
+
whole turns. A changed conversation requires reading it again before export.
|
|
16
|
+
|
|
17
|
+
--branch selected follows the exported selected branch when available.
|
|
18
|
+
--branch all includes alternate exported branches. Unknown branch information
|
|
19
|
+
remains labeled unknown; the server does not infer a hosted conversation state.
|
|
20
|
+
--cursor resumes a read page in the same revision and branch; it is opaque.
|
|
21
|
+
--budget is 256–32000 UTF-8 bytes for the complete returned context block.
|
|
22
|
+
|
|
23
|
+
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
24
|
+
List providers: auto (all), chatgpt, claude, claude-code, codex, generic
|
|
25
|
+
Export stdout contains only the returned context, without an added newline.
|
|
26
|
+
Notices and omitted selections go to stderr. --json preserves the full response.
|
|
27
|
+
These commands read imported data; they do not resume or post to a hosted chat.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const PROVIDERS = new Set(['auto', 'chatgpt', 'claude', 'claude-code', 'codex', 'generic']);
|
|
31
|
+
const GLOBAL = ['account', 'server', 'config-dir', 'json'];
|
|
32
|
+
function number(value, name, minimum, maximum, fallback) {
|
|
33
|
+
if (value === undefined && fallback !== undefined) return fallback;
|
|
34
|
+
if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value))
|
|
35
|
+
throw new Error(`--${name} must be a decimal integer between ${minimum} and ${maximum}.`);
|
|
36
|
+
const result = Number(value);
|
|
37
|
+
if (!Number.isSafeInteger(result) || result < minimum || result > maximum)
|
|
38
|
+
throw new Error(`--${name} must be between ${minimum} and ${maximum}.`);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
function token(value, name, maximum = 256) {
|
|
42
|
+
if (typeof value !== 'string' || !value || value.length > maximum || /[\s\u0000-\u001f\u007f]/u.test(value))
|
|
43
|
+
throw new Error(`Supply the exact ${name} returned by sessions read.`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function identifier(value) {
|
|
47
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(value))
|
|
48
|
+
throw new Error('Supply a conversation document ID from sessions list.');
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function branch(value) {
|
|
52
|
+
if (!['selected', 'all'].includes(value)) throw new Error('Choose --branch selected or --branch all explicitly.');
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
export function selections(values) {
|
|
56
|
+
if (!Array.isArray(values) || values.length < 1 || values.length > 20)
|
|
57
|
+
throw new Error('Supply 1–20 explicit --selection tN:START:END ranges copied from sessions read.');
|
|
58
|
+
const ranges = values.map(value => {
|
|
59
|
+
const match = typeof value === 'string' && /^(t(?:0|[1-9]\d{0,4})):(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(value);
|
|
60
|
+
if (!match || match[1].length > 20) throw new Error('Each --selection must be tN:START:END, exactly as displayed by sessions read.');
|
|
61
|
+
const start = number(match[2], 'selection start', 0, 20 * 1024 * 1024);
|
|
62
|
+
const end = number(match[3], 'selection end', 1, 20 * 1024 * 1024);
|
|
63
|
+
if (end <= start) throw new Error('Selection END must be greater than START. Empty or reversed ranges cannot be exported.');
|
|
64
|
+
return { ref: match[1], start_char: start, end_char: end };
|
|
65
|
+
});
|
|
66
|
+
for (let index = 0; index < ranges.length; index++) {
|
|
67
|
+
for (let previous = 0; previous < index; previous++) {
|
|
68
|
+
const a = ranges[index], b = ranges[previous];
|
|
69
|
+
if (a.start_char < b.end_char && b.start_char < a.end_char)
|
|
70
|
+
throw new Error('Selections must not overlap or repeat. Copy distinct ranges from sessions read.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return ranges;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function sessionRequest(rest, options) {
|
|
77
|
+
const [command, id, ...extra] = rest;
|
|
78
|
+
const local = command === 'list' ? ['provider', 'limit', 'offset'] : command === 'read' ? ['branch', 'cursor', 'limit'] : command === 'export' ? ['branch', 'revision', 'selection', 'budget'] : [];
|
|
79
|
+
if (!['list', 'read', 'export'].includes(command)) throw new Error('Choose sessions list, sessions read, or sessions export. Run opencrawl sessions --help.');
|
|
80
|
+
for (const key of Object.keys(options)) if (![...GLOBAL, ...local].includes(key)) throw new Error(`Unsupported sessions ${command} option: --${key}. Run opencrawl sessions --help.`);
|
|
81
|
+
if (extra.length || command === 'list' && id !== undefined) throw new Error(`Unexpected argument for sessions ${command}. Run opencrawl sessions --help.`);
|
|
82
|
+
if (command === 'list') {
|
|
83
|
+
if (options.provider !== undefined && !PROVIDERS.has(options.provider)) throw new Error('Choose a supported --provider from sessions --help.');
|
|
84
|
+
const query = new URLSearchParams({ limit: number(options.limit, 'limit', 1, 200, 20), offset: number(options.offset, 'offset', 0, 1000000000, 0) });
|
|
85
|
+
if (options.provider && options.provider !== 'auto') query.set('provider', options.provider);
|
|
86
|
+
return { command, route: '/api/v1/sessions?' + query, options: { maxResponseBytes: 2 * 1024 * 1024 } };
|
|
87
|
+
}
|
|
88
|
+
const route = `/api/v1/sessions/${encodeURIComponent(identifier(id))}`;
|
|
89
|
+
const requestedBranch = branch(options.branch);
|
|
90
|
+
if (command === 'read') {
|
|
91
|
+
const query = new URLSearchParams({ branch: requestedBranch, limit: number(options.limit, 'limit', 1, 50, 30) });
|
|
92
|
+
if (options.cursor !== undefined) query.set('cursor', token(options.cursor, 'cursor'));
|
|
93
|
+
return { command, route: route + '/turns?' + query, options: { maxResponseBytes: 2 * 1024 * 1024 } };
|
|
94
|
+
}
|
|
95
|
+
if (typeof options.revision !== 'string' || !/^[a-f0-9]{64}$/.test(options.revision)) throw new Error('Supply the exact 64-character revision returned by sessions read.');
|
|
96
|
+
return { command, route: route + '/context', options: { method: 'POST', maxResponseBytes: 1024 * 1024, body: {
|
|
97
|
+
version: token(options.revision, 'revision', 256), branch: requestedBranch,
|
|
98
|
+
selections: selections(options.selection), budget: number(options.budget, 'budget', 256, 32000),
|
|
99
|
+
} } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function notice(value, stderr) { if (typeof value === 'string' && value.trim()) stderr(`OpenCrawl: ${line(value)}\n`); }
|
|
103
|
+
export function validateExport(data, body) {
|
|
104
|
+
if (!data || typeof data.context !== 'string' || data.version !== body.version || data.budget_unit !== 'utf8_bytes' || data.budget !== body.budget ||
|
|
105
|
+
!Number.isSafeInteger(data.context_bytes) || data.context_bytes !== Buffer.byteLength(data.context, 'utf8') || data.context_bytes > body.budget)
|
|
106
|
+
throw new Error('Server returned an invalid or oversized conversation context. Nothing was exported.');
|
|
107
|
+
const key=range=>`${range?.ref}:${range?.start_char}:${range?.end_char}`;
|
|
108
|
+
const wanted=new Set(body.selections.map(key));
|
|
109
|
+
if (!Array.isArray(data.selected) || data.selected.length !== wanted.size || new Set(data.selected.map(key)).size !== wanted.size || data.selected.some(range=>!wanted.has(key(range)))) throw new Error('Server returned a different conversation selection. Nothing was exported.');
|
|
110
|
+
if (!Array.isArray(data.included) || !Array.isArray(data.omitted)) throw new Error('Server returned an invalid conversation context receipt. Nothing was exported.');
|
|
111
|
+
const pending=new Map(body.selections.map(range=>[key(range),range]));
|
|
112
|
+
const citations=new Set();
|
|
113
|
+
const invalid=()=>{throw new Error('Server returned an inconsistent conversation range receipt. Nothing was exported.');};
|
|
114
|
+
for (const item of data.included) {
|
|
115
|
+
const entry=[...pending].find(([,selected])=>item?.ref===selected.ref && item.start_char===selected.start_char && Number.isSafeInteger(item.end_char) && item.end_char>item.start_char && item.end_char<=selected.end_char);
|
|
116
|
+
if (!entry || typeof item.excerpt!=='string' || Array.from(item.excerpt).length!==item.end_char-item.start_char || item.truncated!==(item.end_char!==entry[1].end_char) || typeof item.citation!=='string' || !/^C[1-9]\d{0,2}$/.test(item.citation) || citations.has(item.citation)) invalid();
|
|
117
|
+
citations.add(item.citation);pending.delete(entry[0]);
|
|
118
|
+
}
|
|
119
|
+
for (const item of data.omitted) {
|
|
120
|
+
if (!pending.has(key(item)) || typeof item.reason!=='string') invalid();
|
|
121
|
+
pending.delete(key(item));
|
|
122
|
+
}
|
|
123
|
+
if (pending.size || Boolean(data.context)!==Boolean(data.included.length)) invalid();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function sessionsCommand(rest, options, client, { stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
|
|
127
|
+
const request = sessionRequest(rest, options);
|
|
128
|
+
let data;
|
|
129
|
+
try { data = await client.request(request.route, request.options); }
|
|
130
|
+
catch (error) {
|
|
131
|
+
const guidance = { 401: 'Sign in with a valid key for the intended workspace account.', 403: 'This account cannot read this conversation. Select an authorized account with --account.',
|
|
132
|
+
404: 'The conversation is unavailable to this account. Check sessions list and your selected workspace.',
|
|
133
|
+
409: 'The conversation revision or page cursor changed. Run sessions read again, then select ranges from its current revision.',
|
|
134
|
+
422: 'The branch, cursor, revision or selected range is invalid. Copy the current values from sessions read.' }[error.status];
|
|
135
|
+
throw new Error(guidance ? `OpenCrawl ${error.status}: ${guidance}` : error.message);
|
|
136
|
+
}
|
|
137
|
+
if (request.command === 'export') validateExport(data, request.options.body);
|
|
138
|
+
if (options.json) { stdout(JSON.stringify(data, null, 2) + '\n');return; }
|
|
139
|
+
if (request.command === 'list') {
|
|
140
|
+
if (!Array.isArray(data?.items)) throw new Error('Server returned an invalid conversation list.');
|
|
141
|
+
if (!data.items.length) { stdout('No imported conversations on this page.\n');return; }
|
|
142
|
+
for (const item of data.items) stdout(`${line(item.id)}\t${line(item.metadata?.provider || item.provider || '')}\t${line(item.title)}\n`);
|
|
143
|
+
if (data.has_more) stderr(`More conversations are available; use sessions list --offset ${data.offset + data.items.length}.\n`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (request.command === 'read') {
|
|
147
|
+
if (typeof data?.version !== 'string' || !Array.isArray(data.fragments)) throw new Error('Server returned an invalid conversation page.');
|
|
148
|
+
stdout(`${line(data.document?.title || 'Conversation')}\nRevision: ${line(data.version)}\nBranch: ${line(data.branch?.requested)} (${line(data.branch?.status)})\n`);
|
|
149
|
+
notice(data.branch?.notice, stderr);notice(data.notice, stderr);
|
|
150
|
+
for (const fragment of data.fragments) {
|
|
151
|
+
if (typeof fragment.text !== 'string' || !Number.isSafeInteger(fragment.start_char) || !Number.isSafeInteger(fragment.end_char)) throw new Error('Server returned an invalid conversation fragment.');
|
|
152
|
+
stdout(`\nSelection: ${line(fragment.ref)}:${fragment.start_char}:${fragment.end_char}\n${line(fragment.role)} · ${line(fragment.branch)}${fragment.timestamp ? ' · ' + line(fragment.timestamp) : ''}\n${displayText(fragment.text)}\n`);
|
|
153
|
+
if (fragment.truncated) stderr(`Fragment ${line(fragment.ref)} contains only the displayed character range. Check pagination before selecting additional text.\n`);
|
|
154
|
+
}
|
|
155
|
+
if (data.mode === 'plain') {
|
|
156
|
+
stderr('Structured turn selection is unavailable for this imported record. Plain text can be read, but sessions export cannot guess turn ranges.\n');
|
|
157
|
+
if (typeof data.plain?.text === 'string') stdout(`\n${displayText(data.plain.text)}\n`);
|
|
158
|
+
}
|
|
159
|
+
if (data.next_cursor) stderr(`Next cursor: ${line(data.next_cursor)}\nRead the same conversation and branch with --cursor to continue.\n`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const item of data.omitted) stderr(`Omitted ${line(item.ref)}:${item.start_char}:${item.end_char}: ${line(item.reason)}. Try fewer ranges or a larger explicit --budget.\n`);
|
|
163
|
+
for (const item of data.included) if (item.truncated) stderr(`Included ${line(item.ref)} only through character ${item.end_char}; the full selected range did not fit the byte budget.\n`);
|
|
164
|
+
stdout(data.context);
|
|
165
|
+
}
|
package/src/tasks.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { allowedOptions, decimal } from './jobs.mjs';
|
|
2
|
+
import { displayLine, displayText } from './display.mjs';
|
|
3
|
+
import { coverageWarnings } from './context.mjs';
|
|
4
|
+
|
|
5
|
+
export const TASKS_HELP = `OpenCrawl · reuse a saved question with current evidence
|
|
6
|
+
|
|
7
|
+
opencrawl tasks list [--limit 20] [--offset 0] [--json]
|
|
8
|
+
opencrawl tasks show TASK_ID [--json]
|
|
9
|
+
opencrawl tasks run TASK_ID [--revision N] [--json]
|
|
10
|
+
|
|
11
|
+
Create and edit private saved tasks in OpenCrawl. A run uses the saved question,
|
|
12
|
+
source scope, result limit, and byte budget. It retrieves current accessible
|
|
13
|
+
evidence; it does not sync sources or reuse a cached answer. A selected source
|
|
14
|
+
becoming unavailable blocks the run instead of widening its scope.
|
|
15
|
+
--revision pins a positive integer revision; omit it to run the current task.
|
|
16
|
+
Run writes exact context bytes to stdout and diagnostics to stderr. --json
|
|
17
|
+
returns the complete receipt. An empty successful run has no current evidence.
|
|
18
|
+
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
19
|
+
`;
|
|
20
|
+
const TASK_ID = /^task_[a-f0-9]{32}$/;
|
|
21
|
+
const integer = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
|
|
22
|
+
export function taskRequest(rest, options) {
|
|
23
|
+
const [command, id, ...extra] = rest;
|
|
24
|
+
if (!['list', 'show', 'run'].includes(command)) throw new Error('Choose tasks list, show, or run. Run opencrawl tasks --help.');
|
|
25
|
+
allowedOptions(options, command === 'list' ? ['limit', 'offset'] : command === 'run' ? ['revision'] : [], 'tasks');
|
|
26
|
+
if (extra.length || command === 'list' && id !== undefined) throw new Error('Unexpected tasks argument.');
|
|
27
|
+
if (command === 'list') {
|
|
28
|
+
const limit = decimal(options.limit, 'limit', 1, 20, 20), offset = decimal(options.offset, 'offset', 0, 1000000, 0);
|
|
29
|
+
return {command, limit, offset, route: '/api/v1/tasks?' + new URLSearchParams({limit, offset}), options: {}};
|
|
30
|
+
}
|
|
31
|
+
if (typeof id !== 'string' || !TASK_ID.test(id)) throw new Error('Supply an exact saved task ID from tasks list.');
|
|
32
|
+
const revision = options.revision === undefined ? undefined : decimal(options.revision, 'revision', 1, Number.MAX_SAFE_INTEGER);
|
|
33
|
+
return {command, id, revision, route: '/api/v1/tasks/' + id + (command === 'run' ? '/run' : ''),
|
|
34
|
+
options: command === 'run' ? {method: 'POST', body: revision === undefined ? {} : {expected_revision: revision}} : {}};
|
|
35
|
+
}
|
|
36
|
+
export function validateTask(task, id = task?.id) {
|
|
37
|
+
const scope = task?.scope;
|
|
38
|
+
if (!task || typeof task.id !== 'string' || !TASK_ID.test(task.id) || task.id !== id ||
|
|
39
|
+
typeof task.title !== 'string' || !task.title || Array.from(task.title).length > 120 ||
|
|
40
|
+
typeof task.query !== 'string' || !task.query.trim() || Array.from(task.query).length > 1000 ||
|
|
41
|
+
!integer(task.revision, 1, Number.MAX_SAFE_INTEGER) || !integer(task.limit, 1, 20) || !integer(task.budget, 256, 16384) ||
|
|
42
|
+
!['available', 'blocked'].includes(task.scope_status) || !scope ||
|
|
43
|
+
!(scope.mode === 'all_accessible' && Object.keys(scope).length === 1 || scope.mode === 'selected' && Object.keys(scope).length === 2 && Array.isArray(scope.source_ids) &&
|
|
44
|
+
scope.source_ids.length >= 1 && scope.source_ids.length <= 100 && new Set(scope.source_ids).size === scope.source_ids.length &&
|
|
45
|
+
scope.source_ids.every(value => typeof value === 'string' && value.length > 0 && value.length <= 200)))
|
|
46
|
+
throw new Error('Server returned an invalid saved task. No context was emitted.');
|
|
47
|
+
return task;
|
|
48
|
+
}
|
|
49
|
+
export function validateTaskRun(data, request) {
|
|
50
|
+
const task = validateTask(data?.task, request.id);
|
|
51
|
+
if (task.scope_status !== 'available' || request.revision !== undefined && task.revision !== request.revision ||
|
|
52
|
+
typeof data.context !== 'string' || data.budget_unit !== 'utf8_bytes' ||
|
|
53
|
+
Buffer.byteLength(data.context) !== data.context_bytes || data.context_bytes > task.budget ||
|
|
54
|
+
!Array.isArray(data.citations) || data.citations.length > task.limit || Boolean(data.context) !== Boolean(data.citations.length))
|
|
55
|
+
throw new Error('Server returned an invalid task context receipt. No context was emitted; inspect the saved task before running again.');
|
|
56
|
+
const labels = new Set(), chars = Array.from(data.context);
|
|
57
|
+
let end = 0;
|
|
58
|
+
for (const citation of data.citations) {
|
|
59
|
+
const span = citation?.context_span, excerpt = citation?.excerpt;
|
|
60
|
+
if (!citation || typeof citation.citation !== 'string' || !/^S[1-9]\d*$/.test(citation.citation) || labels.has(citation.citation) ||
|
|
61
|
+
typeof citation.document_id !== 'string' || !citation.document_id ||
|
|
62
|
+
!(typeof citation.source_id === 'string' && citation.source_id || task.scope.mode === 'all_accessible' && citation.source_id === null) ||
|
|
63
|
+
task.scope.mode === 'selected' && !task.scope.source_ids.includes(citation.source_id) ||
|
|
64
|
+
!span || !integer(span.start_char, end, chars.length) || !integer(span.end_char, span.start_char + 1, chars.length) ||
|
|
65
|
+
!excerpt || !integer(excerpt.start_char, 0, Number.MAX_SAFE_INTEGER) || !integer(excerpt.end_char, excerpt.start_char + 1, Number.MAX_SAFE_INTEGER) ||
|
|
66
|
+
span.end_char - span.start_char !== excerpt.end_char - excerpt.start_char)
|
|
67
|
+
throw new Error('Server returned invalid or out-of-scope task citations. No context was emitted.');
|
|
68
|
+
labels.add(citation.citation); end = span.end_char;
|
|
69
|
+
}
|
|
70
|
+
return data;
|
|
71
|
+
}
|
|
72
|
+
export function taskFailure(error) {
|
|
73
|
+
const status = Number.isInteger(error?.status) ? error.status : undefined;
|
|
74
|
+
if (status === 409 && error.code === 'task_changed') return {code: 'task_changed', status,
|
|
75
|
+
message: 'The saved task changed. Run tasks show to review its current question and scope, then explicitly run that revision.'};
|
|
76
|
+
if (status === 409 && error.code === 'task_scope_unavailable') return {code: 'task_scope_unavailable', status,
|
|
77
|
+
message: 'A selected source is unavailable. Review the task in OpenCrawl and restore access or explicitly edit its scope. No broader search was run.'};
|
|
78
|
+
if (status === 409 && error.code === 'task_evidence_changed') return {code: 'task_evidence_changed', status,
|
|
79
|
+
message: 'Source evidence changed or is no longer accessible. Explicitly run this task again to retrieve current evidence. No automatic retry was made.'};
|
|
80
|
+
const message = ({401: 'Sign in with a valid key for the intended workspace.',403: 'This account cannot access this saved task operation.',
|
|
81
|
+
404: 'This saved task is unavailable to this account. Check tasks list and the selected workspace.',
|
|
82
|
+
409: 'The saved task or its source access changed. Review tasks show before explicitly running again.',
|
|
83
|
+
422: 'The task request is invalid. Check its ID and positive integer revision.',429: 'OpenCrawl is busy or a workspace limit was reached. Try the task again later.'})[status] ||
|
|
84
|
+
'The task run could not be confirmed. Check the connection before explicitly running again. No automatic retry was made.';
|
|
85
|
+
return {code: status ? `http_${status}` : 'request_unconfirmed', message, ...(status ? {status} : {})};
|
|
86
|
+
}
|
|
87
|
+
export async function tasksCommand(rest, options, client, {stdout = value => process.stdout.write(value), stderr = value => process.stderr.write(value)} = {}) {
|
|
88
|
+
const request = taskRequest(rest, options);
|
|
89
|
+
let data;
|
|
90
|
+
try { data = await client.request(request.route, {...request.options, maxResponseBytes: 512 * 1024}); }
|
|
91
|
+
catch (error) { const failure = taskFailure(error); if (options.json) stdout(JSON.stringify({error: failure}, null, 2) + '\n'); else stderr(failure.message + '\n'); return 1; }
|
|
92
|
+
if (request.command === 'list') {
|
|
93
|
+
if (!data || !Array.isArray(data.items) || data.items.length > request.limit || data.limit !== request.limit || data.offset !== request.offset)
|
|
94
|
+
throw new Error('Server returned an invalid saved task page.');
|
|
95
|
+
data.items.forEach(task => validateTask(task));
|
|
96
|
+
if (options.json) stdout(JSON.stringify(data, null, 2) + '\n');
|
|
97
|
+
else {
|
|
98
|
+
stdout(data.items.map(task => `${task.id}\tr${task.revision}\t${task.scope_status}\t${displayLine(task.title)}\n`).join('') || 'No saved tasks on this page.\n');
|
|
99
|
+
if (data.items.length === request.limit) stderr(`To check the next page, use --offset ${request.offset + data.items.length}.\n`);
|
|
100
|
+
}
|
|
101
|
+
} else if (request.command === 'show') {
|
|
102
|
+
validateTask(data, request.id);
|
|
103
|
+
stdout(options.json ? JSON.stringify(data, null, 2) + '\n' : `${displayLine(data.title)}\nTask: ${data.id}\nRevision: ${data.revision}\nQuestion: ${displayText(data.query)}\nScope: ${data.scope.mode === 'all_accessible' ? 'All currently accessible sources' : data.scope.source_ids.map(displayLine).join(', ')}\nSource access: ${data.scope_status}\nResult limit: ${data.limit}; context budget: ${data.budget} UTF-8 bytes\n`);
|
|
104
|
+
} else {
|
|
105
|
+
validateTaskRun(data, request);
|
|
106
|
+
if (options.json) stdout(JSON.stringify(data, null, 2) + '\n');
|
|
107
|
+
else {
|
|
108
|
+
stderr(`Task revision ${data.task.revision}: ${data.citations.length} citations, ${data.context_bytes}/${data.task.budget} UTF-8 bytes.\n`);
|
|
109
|
+
if (!data.context) stderr('No current evidence was returned for the saved question and scope.\n');
|
|
110
|
+
for (const warning of coverageWarnings(data)) stderr(displayLine(warning) + '\n');
|
|
111
|
+
stdout(data.context);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|