@getopencrawl/cli 0.0.0-stage → 0.1.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/README.md +322 -2
- package/bin/opencrawl.mjs +553 -0
- package/browser.mjs +166 -0
- package/package.json +25 -4
- package/src/accounts.test.mjs +223 -0
- package/src/agent.mjs +95 -0
- package/src/agent.test.mjs +58 -0
- package/src/answer-contract.mjs +112 -0
- package/src/answer-view.mjs +118 -0
- package/src/browser.test.mjs +72 -0
- package/src/capture.mjs +273 -0
- package/src/capture.test.mjs +171 -0
- package/src/client.mjs +183 -0
- package/src/client.test.mjs +34 -0
- package/src/connect.mjs +230 -0
- package/src/connect.test.mjs +131 -0
- package/src/context.mjs +34 -0
- package/src/context.test.mjs +32 -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 +117 -0
- package/src/jobs.test.mjs +67 -0
- package/src/mcp-conversations.test.mjs +80 -0
- package/src/monitors.mjs +146 -0
- package/src/monitors.test.mjs +62 -0
- package/src/product-letter-capabilities.mjs +71 -0
- package/src/product-letter-read.mjs +178 -0
- package/src/product-monitor-login.test.mjs +140 -0
- package/src/product-monitors.mjs +321 -0
- package/src/product-monitors.test.mjs +69 -0
- package/src/research-agent-errors.test.mjs +110 -0
- package/src/research-agent-feedback.test.mjs +28 -0
- package/src/research-agent.mjs +164 -0
- package/src/research-agent.test.mjs +231 -0
- package/src/research-answer-contract.mjs +3 -0
- package/src/research-answer-contract.test.mjs +14 -0
- package/src/research-answer-view.mjs +7 -0
- package/src/research-answer-view.test.mjs +27 -0
- package/src/research-completion.mjs +94 -0
- package/src/research-completion.test.mjs +110 -0
- package/src/research-event.mjs +39 -0
- package/src/research-event.test.mjs +57 -0
- package/src/research-presentation.test.mjs +56 -0
- package/src/research.mjs +141 -0
- package/src/research.test.mjs +97 -0
- package/src/sessions.mjs +165 -0
- package/src/sessions.test.mjs +151 -0
- package/src/tasks.mjs +115 -0
- package/src/tasks.test.mjs +83 -0
- package/src/upload-commands.test.mjs +140 -0
- package/src/uploads.mjs +141 -0
- package/src/uploads.test.mjs +64 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Validation body compiled from sdk/src/answerContract.ts; parity is checked in tests.
|
|
2
|
+
// This optional reader never mutates a run or certifies whole-goal completion.
|
|
3
|
+
// Keep the validation/formatter body identical across the SDK, CLI and web.
|
|
4
|
+
const obj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const keys = (v, names) => Object.keys(v).length === names.length && names.every(k => Object.hasOwn(v, k));
|
|
6
|
+
const int = (v, min = 0, max = Number.MAX_SAFE_INTEGER) => typeof v === 'number' && Number.isSafeInteger(v) && v >= min && v <= max;
|
|
7
|
+
const text = (v, max, min = 1) => typeof v === 'string' && Array.from(v).length >= min && Array.from(v).length <= max && !/[\uD800-\uDFFF]/u.test(v) && !v.includes('\0');
|
|
8
|
+
const digest = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
9
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
10
|
+
const requireValue = (ok) => { if (!ok)
|
|
11
|
+
throw new Error('Invalid optional answer assessment'); };
|
|
12
|
+
const unknownNotice = (c) => `Research assessment for “${c.text}”: the saved source text used for this run does not establish an answer or its contradiction. Scope: ${c.evidence_ids.join(', ')}.`;
|
|
13
|
+
const undeliveredNotice = (c) => `Research assessment for “${c.text}”: answer material was found in the saved source text, but a complete answer was not retained in this result. Scope: ${c.evidence_ids.join(', ')}.`;
|
|
14
|
+
const unassessedNotice = 'Requested-answer coverage was not assessed for this run. The findings and coverage notes remain available.';
|
|
15
|
+
const invalidNotice = 'Requested-answer assessment could not be validated for this run. The findings and coverage notes remain available.';
|
|
16
|
+
const reasons = [null, 'component_contract_invalid', 'component_review_empty', 'goal_representation_bound', 'optional_input_bound', 'optional_output_bound', 'input_binding_invalid', 'review_skipped', 'no_evidence', 'legacy_receipt'];
|
|
17
|
+
function notice(c) { return c.status === 'source_scoped_unknown_assessment' ? unknownNotice(c) : c.status === 'available_but_not_delivered' ? undeliveredNotice(c) : null; }
|
|
18
|
+
export function readAnswerContract(run, hash) {
|
|
19
|
+
if (!Object.hasOwn(run.usage, 'answer_contract'))
|
|
20
|
+
return { state: 'absent', contract: null, notices: [] };
|
|
21
|
+
try {
|
|
22
|
+
const value = run.usage.answer_contract;
|
|
23
|
+
requireValue(new TextEncoder().encode(JSON.stringify(run.usage)).length <= 16384);
|
|
24
|
+
requireValue(obj(value));
|
|
25
|
+
const v = value;
|
|
26
|
+
requireValue(v.version === 1 && v.inventory === 'nonexhaustive' && v.whole_request_completeness === 'not_certified');
|
|
27
|
+
if (keys(v, ['version', 'state', 'reason', 'inventory', 'whole_request_completeness', 'components_omitted'])) {
|
|
28
|
+
requireValue(v.state === 'unassessed' && v.reason === 'optional_output_bound' && int(v.components_omitted, 0, 8));
|
|
29
|
+
return { state: 'unassessed', contract: structuredClone(v), notices: [{ id: 'unassessed', text: unassessedNotice }] };
|
|
30
|
+
}
|
|
31
|
+
requireValue(keys(v, ['version', 'state', 'reason', 'scope', 'source_scope', 'inventory', 'whole_request_completeness', 'goal_sha256', 'components', 'notice_components_omitted', 'gap_notice']));
|
|
32
|
+
requireValue(['assessed_nonexhaustive', 'unassessed'].includes(v.state) && reasons.includes(v.reason) && v.scope === 'admitted_retained_windows_only' && digest(v.goal_sha256));
|
|
33
|
+
requireValue(Array.isArray(v.source_scope) && v.source_scope.length <= 20 && Array.isArray(v.components) && v.components.length <= 8);
|
|
34
|
+
const scopes = new Map();
|
|
35
|
+
for (const s of v.source_scope) {
|
|
36
|
+
requireValue(obj(s) && keys(s, ['evidence_id', 'source_sha256', 'window_text_sha256', 'window_chars', 'source_kind', ...(s.source_kind === 'web_page' ? ['page'] : [])]));
|
|
37
|
+
requireValue(text(s.evidence_id, 80) && s.evidence_id.trim() === s.evidence_id && !/[\u0000-\u0020\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(s.evidence_id) && !scopes.has(s.evidence_id) && digest(s.source_sha256) && digest(s.window_text_sha256) && int(s.window_chars, 1, 16000) && ['indexed', 'web_page'].includes(s.source_kind));
|
|
38
|
+
if (s.source_kind === 'web_page') {
|
|
39
|
+
const p = s.page;
|
|
40
|
+
requireValue(obj(p) && keys(p, ['body_sha256', 'start', 'end', 'total_chars']) && digest(p.body_sha256) && int(p.start, 0, 131071) && int(p.end, 1, 131072) && int(p.total_chars, 1, 131072) && p.start < p.end && p.end <= p.total_chars && p.end - p.start === s.window_chars);
|
|
41
|
+
}
|
|
42
|
+
scopes.set(s.evidence_id, s);
|
|
43
|
+
}
|
|
44
|
+
// Available citations must agree, but uncited scope is retained too. No new
|
|
45
|
+
// source fetch or mutable agent.goal is substituted for the original run.
|
|
46
|
+
for (const f of run.findings)
|
|
47
|
+
for (const cite of f.citations) {
|
|
48
|
+
const s = scopes.get(cite.id);
|
|
49
|
+
requireValue(s && s.window_text_sha256 === cite.body_sha256 && s.source_kind === cite.source_kind);
|
|
50
|
+
if (s?.source_kind === 'web_page')
|
|
51
|
+
requireValue(cite.page && ['body_sha256', 'start', 'end', 'total_chars'].every(k => s.page[k] === cite.page[k]));
|
|
52
|
+
}
|
|
53
|
+
requireValue(v.state === 'unassessed' ? (v.components.length === 0 && v.reason !== null) : (v.components.length > 0 && v.reason === null));
|
|
54
|
+
const ids = new Set(), spans = [], eligible = [];
|
|
55
|
+
const allIds = [...scopes.keys()];
|
|
56
|
+
for (const c of v.components) {
|
|
57
|
+
requireValue(obj(c) && keys(c, ['component_id', 'start', 'end', 'text', 'status', 'reason', 'fact_refs', 'evidence_ids', 'source_state']));
|
|
58
|
+
requireValue(typeof c.component_id === 'string' && /^[CM][1-8]$/.test(c.component_id) && !ids.has(c.component_id));
|
|
59
|
+
ids.add(c.component_id);
|
|
60
|
+
requireValue(int(c.start, 0, 3999) && int(c.end, 1, 4000) && text(c.text, 256) && c.text.trim() && c.end - c.start === Array.from(c.text).length);
|
|
61
|
+
requireValue(['literal_answer', 'literal_negative_answer', 'source_scoped_unknown_assessment', 'available_but_not_delivered', 'unassessed'].includes(c.status));
|
|
62
|
+
requireValue([null, 'linked_finding_rejected', 'request_anchor_not_meaningful', 'review_unassessed'].includes(c.reason) && ['supported', 'explicitly_contradicted', 'not_established', 'unassessed'].includes(c.source_state));
|
|
63
|
+
requireValue(Array.isArray(c.evidence_ids) && c.evidence_ids.length <= 20 && same(c.evidence_ids, allIds.filter(id => c.evidence_ids.includes(id))) && Array.isArray(c.fact_refs) && c.fact_refs.length <= 10);
|
|
64
|
+
const refIds = new Set(), ownIds = new Set();
|
|
65
|
+
for (const r of c.fact_refs) {
|
|
66
|
+
requireValue(obj(r) && keys(r, ['finding_id', 'field', 'qualifier_ordinal', 'literal_text_sha256']) && digest(r.literal_text_sha256));
|
|
67
|
+
const f = run.findings.find(f => f.id === r.finding_id), key = JSON.stringify([r.finding_id, r.field, r.qualifier_ordinal]);
|
|
68
|
+
requireValue(f && !refIds.has(key) && ['claim', 'qualifier'].includes(r.field) && int(r.qualifier_ordinal, r.field === 'claim' ? 0 : 1, r.field === 'claim' ? 0 : f?.qualifiers.length));
|
|
69
|
+
refIds.add(key);
|
|
70
|
+
const literal = r.field === 'claim' ? f.claim : f.qualifiers[r.qualifier_ordinal - 1];
|
|
71
|
+
requireValue(hash(literal) === r.literal_text_sha256);
|
|
72
|
+
f.citations.forEach(cite => ownIds.add(cite.id));
|
|
73
|
+
}
|
|
74
|
+
if (c.status === 'unassessed')
|
|
75
|
+
requireValue(c.source_state === 'unassessed' && ['request_anchor_not_meaningful', 'review_unassessed'].includes(c.reason) && !c.fact_refs.length && !c.evidence_ids.length);
|
|
76
|
+
else if (c.status === 'source_scoped_unknown_assessment')
|
|
77
|
+
requireValue(c.source_state === 'not_established' && c.reason === null && !c.fact_refs.length && allIds.length > 0 && same(c.evidence_ids, allIds));
|
|
78
|
+
else {
|
|
79
|
+
requireValue(['supported', 'explicitly_contradicted'].includes(c.source_state) && c.evidence_ids.length > 0);
|
|
80
|
+
if (c.status === 'available_but_not_delivered')
|
|
81
|
+
requireValue(c.reason === null || c.reason === 'linked_finding_rejected');
|
|
82
|
+
else
|
|
83
|
+
requireValue(c.reason === null && c.fact_refs.length > 0 && c.status === (c.source_state === 'supported' ? 'literal_answer' : 'literal_negative_answer') && same(c.evidence_ids, allIds.filter(id => ownIds.has(id))));
|
|
84
|
+
}
|
|
85
|
+
if (c.reason !== 'request_anchor_not_meaningful') {
|
|
86
|
+
requireValue(spans.every(p => p.text !== c.text && (p.end <= c.start || p.start >= c.end)));
|
|
87
|
+
spans.push(c);
|
|
88
|
+
}
|
|
89
|
+
if (notice(c))
|
|
90
|
+
eligible.push(c);
|
|
91
|
+
}
|
|
92
|
+
requireValue(int(v.notice_components_omitted, 0, eligible.length));
|
|
93
|
+
let delivered = [];
|
|
94
|
+
if (v.gap_notice !== null) {
|
|
95
|
+
const g = v.gap_notice;
|
|
96
|
+
requireValue(obj(g) && keys(g, ['index', 'sha256', 'component_ids']) && int(g.index, 0, run.gaps.length - 1) && g.index === run.gaps.length - 1 && digest(g.sha256) && hash(run.gaps[g.index]) === g.sha256);
|
|
97
|
+
requireValue(Array.isArray(g.component_ids) && g.component_ids.length > 0 && same(g.component_ids, eligible.filter(c => g.component_ids.includes(c.component_id)).map(c => c.component_id)));
|
|
98
|
+
const suffix = ' ' + eligible.filter(c => g.component_ids.includes(c.component_id)).map(c => notice(c)).join(' ');
|
|
99
|
+
requireValue(run.gaps[g.index].endsWith(suffix));
|
|
100
|
+
delivered = g.component_ids;
|
|
101
|
+
}
|
|
102
|
+
requireValue(v.notice_components_omitted === eligible.length - delivered.length);
|
|
103
|
+
const notices = v.state === 'unassessed' ? [{ id: 'unassessed', text: unassessedNotice }] : v.components.flatMap((c) => {
|
|
104
|
+
const n = notice(c);
|
|
105
|
+
return n && !delivered.includes(c.component_id) ? [{ id: c.component_id, text: n }] : c.status === 'unassessed' ? [{ id: c.component_id, text: `Research assessment for “${c.text}”: requested-answer coverage was not assessed.` }] : [];
|
|
106
|
+
});
|
|
107
|
+
return { state: v.state, contract: structuredClone(v), notices };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { state: 'invalid', contract: null, notices: [{ id: 'invalid', text: invalidNotice }] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Validation body compiled from sdk/src/answerView.ts; parity is checked in tests.
|
|
2
|
+
const obj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
3
|
+
const keys = (v, names) => Object.keys(v).length === names.length && names.every(k => Object.hasOwn(v, k));
|
|
4
|
+
const need = (ok) => { if (!ok)
|
|
5
|
+
throw new Error('Invalid optional concise answer'); };
|
|
6
|
+
const digest = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
7
|
+
const refKey = (r) => JSON.stringify([r.finding_id, r.field, r.qualifier_ordinal, r.literal_text_sha256]);
|
|
8
|
+
const empty = (state) => ({ state, view: null, sections: [] });
|
|
9
|
+
const byteLength = (text) => new TextEncoder().encode(text).length;
|
|
10
|
+
/** Python's sorted-key, compact, ensure_ascii=False JSON for bounded public data.
|
|
11
|
+
* Literal strings are not normalized. Reject lossy JSON values and lone surrogates.
|
|
12
|
+
*/
|
|
13
|
+
export function canonicalAnswerJson(value) {
|
|
14
|
+
let nodes = 0;
|
|
15
|
+
const scalar = (text) => { need(!/[\uD800-\uDFFF]/u.test(text)); return JSON.stringify(text); };
|
|
16
|
+
const compare = (a, b) => { const x = Array.from(a), y = Array.from(b); for (let i = 0; i < Math.min(x.length, y.length); i++) {
|
|
17
|
+
const d = x[i].codePointAt(0) - y[i].codePointAt(0);
|
|
18
|
+
if (d)
|
|
19
|
+
return d;
|
|
20
|
+
} return x.length - y.length; };
|
|
21
|
+
const encode = (v, depth) => {
|
|
22
|
+
need(++nodes <= 8192 && depth <= 24);
|
|
23
|
+
if (v === null)
|
|
24
|
+
return 'null';
|
|
25
|
+
if (typeof v === 'string')
|
|
26
|
+
return scalar(v);
|
|
27
|
+
if (typeof v === 'boolean')
|
|
28
|
+
return v ? 'true' : 'false';
|
|
29
|
+
if (typeof v === 'number') {
|
|
30
|
+
need(Number.isSafeInteger(v) && !Object.is(v, -0));
|
|
31
|
+
return String(v);
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(v))
|
|
34
|
+
return '[' + Array.from(v, item => encode(item, depth + 1)).join(',') + ']';
|
|
35
|
+
need(obj(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
36
|
+
return '{' + Object.keys(v).sort(compare).map(k => scalar(k) + ':' + encode(v[k], depth + 1)).join(',') + '}';
|
|
37
|
+
};
|
|
38
|
+
const result = encode(value, 0);
|
|
39
|
+
need(byteLength(result) <= 65536);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function omission(c, reason) {
|
|
43
|
+
if (reason === 'source_scoped_unknown')
|
|
44
|
+
return 'Not established in the saved sources for this part.';
|
|
45
|
+
if (reason === 'answer_not_delivered')
|
|
46
|
+
return 'Answer material was found in the saved source text, but a complete answer was not retained in this result.';
|
|
47
|
+
return c.status === 'unassessed' ? 'Review the exact evidence for this part. Requested-answer coverage was not assessed.' : 'Review the exact evidence for this part. Concise prose was not retained.';
|
|
48
|
+
}
|
|
49
|
+
/** The wrappers supply the existing validated contract. Optional failure never
|
|
50
|
+
* changes a run, its findings/gaps, or the original contract's coverage notices.
|
|
51
|
+
*/
|
|
52
|
+
export function readAnswerView(run, contractView, hash) {
|
|
53
|
+
if (!Object.hasOwn(run.usage, 'answer_view'))
|
|
54
|
+
return empty('absent');
|
|
55
|
+
try {
|
|
56
|
+
need(contractView.state === 'assessed_nonexhaustive' && contractView.contract !== null);
|
|
57
|
+
const contract = contractView.contract, value = run.usage.answer_view;
|
|
58
|
+
need(obj(value));
|
|
59
|
+
const v = value;
|
|
60
|
+
need(keys(v, ['version', 'goal_sha256', 'answer_contract_sha256', 'findings_sha256', 'sections', 'omitted']) && v.version === 1);
|
|
61
|
+
need(byteLength(canonicalAnswerJson(v)) <= 8192 && byteLength(canonicalAnswerJson(run.usage)) <= 16384);
|
|
62
|
+
need(run.findings.length <= 5 && run.findings.every(f => f.qualifiers.length <= 5 && [f.claim, ...f.qualifiers].every(t => typeof t === 'string' && Array.from(t).length <= 1500)));
|
|
63
|
+
need(digest(v.goal_sha256) && v.goal_sha256 === contract.goal_sha256 && digest(v.answer_contract_sha256) && digest(v.findings_sha256));
|
|
64
|
+
need(hash(canonicalAnswerJson(run.usage.answer_contract)) === v.answer_contract_sha256 && hash(canonicalAnswerJson(run.findings)) === v.findings_sha256);
|
|
65
|
+
need(Array.isArray(v.sections) && v.sections.length <= 8 && Array.isArray(v.omitted) && v.omitted.length <= 8);
|
|
66
|
+
const ordered = [...contract.components].sort((a, b) => a.start - b.start || a.end - b.end), byId = new Map(ordered.map(c => [c.component_id, c]));
|
|
67
|
+
const sections = new Map(), omitted = new Map();
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (const s of v.sections) {
|
|
70
|
+
need(obj(s) && keys(s, ['component_id', 'assertions']) && !sections.has(s.component_id));
|
|
71
|
+
const c = byId.get(s.component_id);
|
|
72
|
+
need(c && ['literal_answer', 'literal_negative_answer'].includes(c.status));
|
|
73
|
+
need(Array.isArray(s.assertions) && s.assertions.length >= 1 && s.assertions.length <= 2);
|
|
74
|
+
const expected = new Set(c.fact_refs.map(refKey)), actual = new Set();
|
|
75
|
+
for (const a of s.assertions) {
|
|
76
|
+
need(obj(a) && keys(a, ['text', 'fact_refs']) && typeof a.text === 'string' && a.text === a.text.trim() && a.text.length > 0 && !/\p{C}/u.test(a.text));
|
|
77
|
+
total += Array.from(a.text).length;
|
|
78
|
+
need(total <= 1800);
|
|
79
|
+
need(Array.isArray(a.fact_refs) && a.fact_refs.length >= 1 && a.fact_refs.length <= 10);
|
|
80
|
+
const own = new Set();
|
|
81
|
+
for (const r of a.fact_refs) {
|
|
82
|
+
need(obj(r) && keys(r, ['finding_id', 'field', 'qualifier_ordinal', 'literal_text_sha256']));
|
|
83
|
+
const finding = run.findings.find(f => f.id === r.finding_id);
|
|
84
|
+
need(finding && ['claim', 'qualifier'].includes(r.field) && Number.isSafeInteger(r.qualifier_ordinal) && digest(r.literal_text_sha256));
|
|
85
|
+
need(r.field === 'claim' ? r.qualifier_ordinal === 0 : r.qualifier_ordinal >= 1 && r.qualifier_ordinal <= finding.qualifiers.length);
|
|
86
|
+
const literal = r.field === 'claim' ? finding.claim : finding.qualifiers[r.qualifier_ordinal - 1];
|
|
87
|
+
need(typeof literal === 'string' && hash(literal) === r.literal_text_sha256);
|
|
88
|
+
const key = refKey(r);
|
|
89
|
+
need(!own.has(key));
|
|
90
|
+
own.add(key);
|
|
91
|
+
actual.add(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Extra reviewed, retained fields may carry necessary context. They never
|
|
95
|
+
// substitute for a required component field or a missing qualification.
|
|
96
|
+
need([...expected].every(key => actual.has(key)));
|
|
97
|
+
sections.set(s.component_id, s.assertions);
|
|
98
|
+
}
|
|
99
|
+
for (const o of v.omitted) {
|
|
100
|
+
need(obj(o) && keys(o, ['component_id', 'reason']) && !sections.has(o.component_id) && !omitted.has(o.component_id));
|
|
101
|
+
const c = byId.get(o.component_id);
|
|
102
|
+
need(c);
|
|
103
|
+
const allowed = c.status === 'source_scoped_unknown_assessment' ? ['source_scoped_unknown'] : c.status === 'available_but_not_delivered' ? ['answer_not_delivered'] : c.status === 'unassessed' ? ['unassessed'] : ['prose_not_supplied', 'prose_review_failed', 'fact_binding_failed'];
|
|
104
|
+
need(allowed.includes(o.reason));
|
|
105
|
+
omitted.set(o.component_id, o.reason);
|
|
106
|
+
}
|
|
107
|
+
need(sections.size + omitted.size === ordered.length);
|
|
108
|
+
need(JSON.stringify([...sections.keys()]) === JSON.stringify(ordered.filter(c => sections.has(c.component_id)).map(c => c.component_id)));
|
|
109
|
+
need(JSON.stringify([...omitted.keys()]) === JSON.stringify(ordered.filter(c => omitted.has(c.component_id)).map(c => c.component_id)));
|
|
110
|
+
return { state: 'valid', view: structuredClone(v), sections: structuredClone(ordered.map(component => {
|
|
111
|
+
const reason = omitted.get(component.component_id) ?? null;
|
|
112
|
+
return { component, assertions: sections.get(component.component_id) ?? [], omission_reason: reason, notice: reason === null ? null : omission(component, reason) };
|
|
113
|
+
})) };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return empty('invalid');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { validateCaptureUrl, isReadOnlyRequest, browserCommand } from '../browser.mjs';
|
|
7
|
+
|
|
8
|
+
test('browser scope rejects executable schemes and action links', () => {
|
|
9
|
+
for (const url of ['file:///etc/passwd', 'javascript:alert(1)', 'https://x.test/logout', 'https://x.test/%64elete/123', 'https://x.test/?action=delete', 'https://user:password@x.test']) {
|
|
10
|
+
assert.throws(() => validateCaptureUrl(url));
|
|
11
|
+
}
|
|
12
|
+
assert.equal(validateCaptureUrl('https://crm.example/contacts#top').href, 'https://crm.example/contacts');
|
|
13
|
+
});
|
|
14
|
+
test('only read HTTP methods and GraphQL queries are accepted', () => {
|
|
15
|
+
assert.equal(isReadOnlyRequest('GET'), true);
|
|
16
|
+
assert.equal(isReadOnlyRequest('DELETE'), false);
|
|
17
|
+
assert.equal(isReadOnlyRequest('POST', '{"name":"Change me"}'), false);
|
|
18
|
+
assert.equal(isReadOnlyRequest('POST', JSON.stringify({ query: 'query Contacts { contacts { name } }' })), true);
|
|
19
|
+
assert.equal(isReadOnlyRequest('POST', JSON.stringify({ query: 'mutation DeleteContact { deleteContact(id: 1) }' })), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function browserFixture() {
|
|
23
|
+
const observations = { urls: [], closed: false, documents: [], routes: [] };
|
|
24
|
+
const frame = {};
|
|
25
|
+
let current = 'about:blank';
|
|
26
|
+
const page = {
|
|
27
|
+
goto: async url => { current = url; observations.urls.push(url); return { status: () => 200 }; },
|
|
28
|
+
url: () => current, mainFrame: () => frame,
|
|
29
|
+
waitForLoadState: async () => {},
|
|
30
|
+
evaluate: async () => ({ title: 'Customer account', text: 'Acme customer account. Renewal is on November 15. Owner is Jamie.', links: [
|
|
31
|
+
'https://crm.example/accounts/next', 'https://crm.example/logout', 'https://unrelated.example/private',
|
|
32
|
+
], headings: ['Acme'] }),
|
|
33
|
+
locator: () => ({}), screenshot: async ({ path: output }) => fs.writeFile(output, 'test screenshot'),
|
|
34
|
+
};
|
|
35
|
+
const context = {
|
|
36
|
+
pages: () => [page], newPage: async () => page, route: async (match, callback) => observations.routes.push(callback),
|
|
37
|
+
on: () => {}, close: async () => { observations.closed = true; },
|
|
38
|
+
};
|
|
39
|
+
const playwright = { chromium: { launchPersistentContext: async (dir, options) => { observations.profile = dir; observations.options = options; return context; } } };
|
|
40
|
+
const client = { configured: true, request: async (route, { body }) => { observations.documents.push(body); return { id: 'document-' + observations.documents.length }; } };
|
|
41
|
+
return { observations, playwright, client };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('capture imports provenance using isolated profile and blocks mutations', async () => {
|
|
45
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-browser-test-'));
|
|
46
|
+
const prior = process.env.OPENCRAWL_CONFIG_DIR; process.env.OPENCRAWL_CONFIG_DIR = directory;
|
|
47
|
+
try {
|
|
48
|
+
const { observations, playwright, client } = browserFixture();
|
|
49
|
+
const result = await browserCommand('capture', 'https://crm.example/accounts/123', { headless: true }, client, { playwright });
|
|
50
|
+
assert.equal(result.imported, 1);
|
|
51
|
+
assert.equal(observations.documents[0].visibility, 'private');
|
|
52
|
+
assert.equal(observations.documents[0].url, 'https://crm.example/accounts/123');
|
|
53
|
+
assert.ok(observations.profile.startsWith(path.join(directory, 'browser-profiles')));
|
|
54
|
+
assert.equal(observations.options.serviceWorkers, 'block');
|
|
55
|
+
assert.equal(observations.closed, true);
|
|
56
|
+
let aborted = false;
|
|
57
|
+
await observations.routes[0]({ request: () => ({ method: () => 'DELETE', postData: () => null }), abort: async () => { aborted = true; } });
|
|
58
|
+
assert.equal(aborted, true);
|
|
59
|
+
await assert.rejects(browserCommand('capture', 'https://other.example/page', {}, client, { playwright }), /scoped to/);
|
|
60
|
+
} finally { if (prior === undefined) delete process.env.OPENCRAWL_CONFIG_DIR; else process.env.OPENCRAWL_CONFIG_DIR = prior; await fs.rm(directory, { recursive: true, force: true }); }
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('bounded crawl excludes other origins and action links', async () => {
|
|
64
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'opencrawl-crawl-test-'));
|
|
65
|
+
const prior = process.env.OPENCRAWL_CONFIG_DIR; process.env.OPENCRAWL_CONFIG_DIR = directory;
|
|
66
|
+
try {
|
|
67
|
+
const { observations, playwright, client } = browserFixture();
|
|
68
|
+
const result = await browserCommand('crawl', 'https://crm.example/accounts/123', { 'max-pages': '2', 'path-prefix': '/accounts/' }, client, { playwright });
|
|
69
|
+
assert.equal(result.imported, 2);
|
|
70
|
+
assert.deepEqual(observations.urls, ['https://crm.example/accounts/123', 'https://crm.example/accounts/next']);
|
|
71
|
+
} finally { if (prior === undefined) delete process.env.OPENCRAWL_CONFIG_DIR; else process.env.OPENCRAWL_CONFIG_DIR = prior; await fs.rm(directory, { recursive: true, force: true }); }
|
|
72
|
+
});
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { configDir, createClient } from './client.mjs';
|
|
6
|
+
import { allowedOptions, decimal } from './jobs.mjs';
|
|
7
|
+
import { displayLine } from './display.mjs';
|
|
8
|
+
|
|
9
|
+
export const CAPTURE_HELP = `OpenCrawl · capture new project conversations
|
|
10
|
+
|
|
11
|
+
opencrawl install claude-code|codex --capture
|
|
12
|
+
opencrawl capture status --provider claude-code|codex [--json]
|
|
13
|
+
opencrawl capture flush --provider claude-code|codex [--timeout 10] [--json]
|
|
14
|
+
opencrawl capture pause|resume --provider claude-code|codex
|
|
15
|
+
|
|
16
|
+
The current project is the default; --project DIRECTORY selects an installed
|
|
17
|
+
project explicitly. New user prompts and final assistant messages are private.
|
|
18
|
+
No transcript files, old chats, tools, reasoning, or other projects are read.
|
|
19
|
+
Status reads saved receipts and current server readiness. Flush explicitly
|
|
20
|
+
replays only immutable queued events using server idempotence, for 1–60 seconds.
|
|
21
|
+
Unknown delivery is never called indexed. Conflicts/deleted sessions stay blocked.
|
|
22
|
+
Pause stops new local capture; resume captures future events, not missed history.
|
|
23
|
+
Persist the owner-only OpenCrawl configuration directory in disposable sandboxes.
|
|
24
|
+
Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
|
|
25
|
+
Environment-key install: --credential-env NAME (name only; key never in settings).
|
|
26
|
+
`;
|
|
27
|
+
export const AGENT_OPTIONS = ['credential-env','expect-user','expect-workspace'];
|
|
28
|
+
const PROVIDERS = ['claude-code','codex'];
|
|
29
|
+
const MAX_STORE = 8 * 1024 * 1024, MAX_CONTENT = 64 * 1024, MAX_SESSIONS = 100, MAX_EVENTS = 20000;
|
|
30
|
+
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
31
|
+
const fail = message => { throw new Error(message); };
|
|
32
|
+
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\u0000-\u0020\u007f]/.test(value);
|
|
33
|
+
const user = value => /^usr_[a-f0-9]{32}$/.test(value || '');
|
|
34
|
+
const workspace = value => /^ws_[a-f0-9]{32}$/.test(value || '');
|
|
35
|
+
const same = (a,b) => JSON.stringify(a) === JSON.stringify(b);
|
|
36
|
+
|
|
37
|
+
/** Explicit environment credentials require a destination and durable identity pins. */
|
|
38
|
+
export async function agentClient(options, { installing = false } = {}) {
|
|
39
|
+
if (options['credential-env'] === undefined) {
|
|
40
|
+
if (options['expect-user'] !== undefined || options['expect-workspace'] !== undefined) fail('Identity pins require --credential-env.');
|
|
41
|
+
return createClient(options);
|
|
42
|
+
}
|
|
43
|
+
const name = options['credential-env'];
|
|
44
|
+
if (!/^[A-Z][A-Z0-9_]{1,79}$/.test(name) || !options.server || options.account) fail('Environment-key setup requires an explicit --server, a variable name, and no saved --account.');
|
|
45
|
+
if (!installing && (!user(options['expect-user']) || !workspace(options['expect-workspace']))) fail('Environment-key agent setup needs verified identity pins. Rerun the installer in this runtime.');
|
|
46
|
+
const token = process.env[name];
|
|
47
|
+
if (!token) fail('The configured credential environment variable is missing. No saved key was substituted.');
|
|
48
|
+
const client = await createClient({ server: options.server, token });
|
|
49
|
+
client.expectedIdentity = installing ? null : { user_id: options['expect-user'], workspace_id: options['expect-workspace'] };
|
|
50
|
+
return client;
|
|
51
|
+
}
|
|
52
|
+
export function bindingIdentity(client) {
|
|
53
|
+
const value = client.expectedIdentity || (client.profile && { user_id: client.profile.userId, workspace_id: client.profile.workspaceId });
|
|
54
|
+
if (!client.configured || !value || !user(value.user_id) || !workspace(value.workspace_id)) fail('Capture requires a verified saved account or an installed environment-key identity.');
|
|
55
|
+
return { server: client.baseUrl, ...value };
|
|
56
|
+
}
|
|
57
|
+
export async function verifyAgentIdentity(client, timeout = 2000) {
|
|
58
|
+
const result = await client.request('/api/auth/me', { timeout, maxResponseBytes: 128 * 1024 });
|
|
59
|
+
const actual = { user_id: result?.user?.id, workspace_id: result?.workspace?.id };
|
|
60
|
+
if (!user(actual.user_id) || !workspace(actual.workspace_id)) fail('The server did not return a valid account identity.');
|
|
61
|
+
const expected = client.expectedIdentity || (client.profile && { user_id: client.profile.userId, workspace_id: client.profile.workspaceId });
|
|
62
|
+
if (expected && !same(actual, expected)) fail('The credential does not match the installed user and workspace. Nothing was sent.');
|
|
63
|
+
return { server: client.baseUrl, ...actual };
|
|
64
|
+
}
|
|
65
|
+
async function privateDirectory(directory) {
|
|
66
|
+
await fs.mkdir(directory,{recursive:true,mode:0o700});
|
|
67
|
+
const stat=await fs.lstat(directory);
|
|
68
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || typeof process.getuid==='function' && stat.uid!==process.getuid()) fail('Capture state must be in a directory owned by this user, without symbolic links.');
|
|
69
|
+
await fs.chmod(directory,0o700);
|
|
70
|
+
}
|
|
71
|
+
async function projectScope(client, provider, project) {
|
|
72
|
+
if (!PROVIDERS.includes(provider)) fail('Choose --provider claude-code or codex.');
|
|
73
|
+
const root=await fs.realpath(project || process.cwd());
|
|
74
|
+
if (!(await fs.stat(root)).isDirectory()) fail('Capture project must be a directory.');
|
|
75
|
+
return { ...bindingIdentity(client), provider, project: root };
|
|
76
|
+
}
|
|
77
|
+
async function storage(scope, action, { initialize=false }={}) {
|
|
78
|
+
const root=path.resolve(configDir());await privateDirectory(root);
|
|
79
|
+
const directory=path.join(root,'capture');await privateDirectory(directory);
|
|
80
|
+
const name=hash(JSON.stringify(scope)),file=path.join(directory,name+'.json'),lockfile=path.join(directory,name+'.lock');
|
|
81
|
+
let lock;
|
|
82
|
+
const until=Date.now()+700;
|
|
83
|
+
while (!lock) {
|
|
84
|
+
try { lock=await fs.open(lockfile,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);await lock.writeFile(String(process.pid)); }
|
|
85
|
+
catch (error) { if(error.code!=='EEXIST')throw error;if(Date.now()>=until)fail('Capture is busy or has a stale lock. Inspect capture status; remove its lock only after confirming the previous process stopped.');await new Promise(resolve=>setTimeout(resolve,25)); }
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
let state,handle;
|
|
89
|
+
try {
|
|
90
|
+
handle=await fs.open(file,constants.O_RDONLY|constants.O_NOFOLLOW);const stat=await handle.stat();
|
|
91
|
+
if(!stat.isFile()||stat.size>MAX_STORE||stat.mode&0o077||typeof process.getuid==='function'&&stat.uid!==process.getuid())fail('Capture state must be an owner-only bounded regular file.');
|
|
92
|
+
try { state=JSON.parse(await handle.readFile('utf8')); } catch { fail('Capture state is invalid. Preserve it before repair; no events were sent.'); }
|
|
93
|
+
} catch(error) {
|
|
94
|
+
if(error.code!=='ENOENT')throw error;
|
|
95
|
+
if(!initialize)fail('Capture is not configured for this project and account. Run install TARGET --capture here.');
|
|
96
|
+
if((await fs.readdir(directory)).filter(value=>/^[a-f0-9]{64}\.json$/.test(value)).length>=64)fail('Capture is limited to 64 project/account configurations. Preserve existing state before starting another.');
|
|
97
|
+
state={version:1,scope,paused:false,sessions:[],configured_at:new Date().toISOString()};
|
|
98
|
+
} finally { await handle?.close(); }
|
|
99
|
+
validateState(state,scope);
|
|
100
|
+
const save=async()=>{
|
|
101
|
+
const encoded=JSON.stringify(state);if(Buffer.byteLength(encoded)>MAX_STORE)fail('Capture spool reached its 8 MiB limit. Flush or review queued events; no content was silently dropped.');
|
|
102
|
+
const temp=file+'.'+crypto.randomUUID()+'.tmp';let output;
|
|
103
|
+
try { output=await fs.open(temp,'wx',0o600);await output.writeFile(encoded);await output.sync();await output.close();output=null;await fs.rename(temp,file);
|
|
104
|
+
if(process.platform!=='win32'){const dir=await fs.open(directory,constants.O_RDONLY|constants.O_DIRECTORY|constants.O_NOFOLLOW);try{await dir.sync();}finally{await dir.close();}} }
|
|
105
|
+
finally {await output?.close();await fs.unlink(temp).catch(error=>{if(error.code!=='ENOENT')throw error;});}
|
|
106
|
+
};
|
|
107
|
+
return await action(state,save);
|
|
108
|
+
} finally {await lock.close();await fs.unlink(lockfile);}
|
|
109
|
+
}
|
|
110
|
+
function validateState(state,scope) {
|
|
111
|
+
if(state?.version!==1||!same(state.scope,scope)||typeof state.paused!=='boolean'||!Array.isArray(state.sessions)||state.sessions.length>MAX_SESSIONS)fail('Capture binding or state is invalid. No events were sent.');
|
|
112
|
+
let count=0;
|
|
113
|
+
for(const session of state.sessions) {
|
|
114
|
+
if(!identifier(session.id)||!identifier(session.remote_id)||!Array.isArray(session.events)||!Number.isSafeInteger(session.next)||session.next<0||!Array.isArray(session.turns))fail('Capture session state is invalid.');
|
|
115
|
+
const ids=new Set(),sequences=new Set();
|
|
116
|
+
for(const event of session.events) {
|
|
117
|
+
if(!identifier(event.id)||ids.has(event.id)||!Number.isSafeInteger(event.sequence)||event.sequence<0||event.sequence>=session.next||sequences.has(event.sequence)||!['user','assistant'].includes(event.role)||!['queued','acknowledged'].includes(event.state)||!/^[a-f0-9]{64}$/.test(event.hash)||!Number.isInteger(event.attempts)||event.attempts<0)fail('Capture event state is invalid.');
|
|
118
|
+
if(event.state==='queued'&&(typeof event.content!=='string'||!event.content.trim()||Buffer.byteLength(event.content)>MAX_CONTENT||hash(event.content)!==event.hash))fail('Queued capture content is invalid.');
|
|
119
|
+
ids.add(event.id);sequences.add(event.sequence);count++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if(count>MAX_EVENTS)fail('Capture state exceeds its event limit.');
|
|
123
|
+
}
|
|
124
|
+
export async function configureCapture(client,provider,project) {
|
|
125
|
+
const scope=await projectScope(client,provider,project);
|
|
126
|
+
await storage(scope,async(state,save)=>{await save();},{initialize:true});return scope;
|
|
127
|
+
}
|
|
128
|
+
function safeIssue(error) {
|
|
129
|
+
const status=error?.status;
|
|
130
|
+
return ({401:'authorization_required',403:'authorization_required',409:'event_conflict',410:'session_deleted',413:'capacity_exceeded',422:'invalid_event',429:'temporarily_unavailable',507:'storage_unavailable'})[status] || 'delivery_unconfirmed';
|
|
131
|
+
}
|
|
132
|
+
const permanent = issue => ['event_conflict','session_deleted','invalid_event','capacity_exceeded'].includes(issue);
|
|
133
|
+
export function validateCaptureReceipt(value,batchCount=null) {
|
|
134
|
+
if(!value||typeof value.document_id!=='string'||!/^doc_[a-f0-9]{32}$/.test(value.document_id)||value.lexical_ready!==true||typeof value.semantic_ready!=='boolean'||!Number.isSafeInteger(value.event_count)||value.event_count<1||!['ready','pending','unavailable'].includes(value.semantic?.status)||!Number.isSafeInteger(value.semantic?.indexed_chunks)||!Number.isSafeInteger(value.semantic?.total_chunks)||value.semantic.indexed_chunks<0||value.semantic.total_chunks<value.semantic.indexed_chunks)fail('Capture acknowledgement is invalid; queued events were preserved.');
|
|
135
|
+
if(batchCount!==null&&(!Number.isSafeInteger(value.accepted_events)||!Number.isSafeInteger(value.replayed_events)||value.accepted_events<0||value.replayed_events<0||value.accepted_events+value.replayed_events!==batchCount))fail('Capture acknowledgement did not account for this exact event batch.');
|
|
136
|
+
return {document_id:value.document_id,lexical_ready:true,semantic_ready:value.semantic_ready,semantic:{status:value.semantic.status,indexed_chunks:value.semantic.indexed_chunks,total_chunks:value.semantic.total_chunks},event_count:value.event_count,observed_at:new Date().toISOString()};
|
|
137
|
+
}
|
|
138
|
+
function enqueue(state,input) {
|
|
139
|
+
if(state.paused)return null;
|
|
140
|
+
const role=input.hook_event_name==='UserPromptSubmit'?'user':input.hook_event_name==='Stop'?'assistant':null;
|
|
141
|
+
if(!role||!identifier(input.session_id))fail('Unsupported capture hook or session identifier.');
|
|
142
|
+
const content=role==='user'?input.prompt:input.last_assistant_message;
|
|
143
|
+
if(typeof content!=='string'||!content.trim())return null;
|
|
144
|
+
if(Buffer.byteLength(content)>MAX_CONTENT)fail('This message exceeds the 64 KiB capture limit; it was not truncated or uploaded.');
|
|
145
|
+
if(Buffer.byteLength(JSON.stringify(content))>230*1024)fail('This message exceeds the encoded capture request limit; it was not truncated or uploaded.');
|
|
146
|
+
let session=state.sessions.find(value=>value.id===input.session_id);
|
|
147
|
+
if(!session) {
|
|
148
|
+
if(role==='assistant')return null; // Never attach an orphan Stop to a guessed turn.
|
|
149
|
+
if(state.sessions.length>=MAX_SESSIONS)fail('This capture scope reached 100 sessions. Preserve its state before starting another.');
|
|
150
|
+
session={id:input.session_id,remote_id:'occap_'+hash(state.scope.project+'\0'+input.session_id),events:[],turns:[],next:0};state.sessions.push(session);
|
|
151
|
+
}
|
|
152
|
+
if(permanent(session.issue))return session;
|
|
153
|
+
let turn;
|
|
154
|
+
if(state.scope.provider==='codex') {
|
|
155
|
+
if(!identifier(input.turn_id))fail('This Codex version did not provide a turn ID. Capture skipped; update the client and inspect /hooks.');
|
|
156
|
+
turn=session.turns.find(value=>value.id===input.turn_id);
|
|
157
|
+
if(!turn) {if(role==='assistant')return null;turn={id:input.turn_id,token:hash(input.turn_id)};session.turns.push(turn);}
|
|
158
|
+
} else if(role==='user') {
|
|
159
|
+
// prompt_id is optional in Claude versions. Without it, an unfinished
|
|
160
|
+
// identical prompt is treated as a hook replay; completed turns are new.
|
|
161
|
+
const last=session.turns.at(-1), native=identifier(input.prompt_id)?input.prompt_id:null;
|
|
162
|
+
if(native)turn=session.turns.find(value=>value.id===native);
|
|
163
|
+
else if(last&&!last.closed&&last.user_hash===hash(content))turn=last;
|
|
164
|
+
if(!turn){turn={id:native||crypto.randomUUID(),token:crypto.randomUUID(),user_hash:hash(content)};session.turns.push(turn);}
|
|
165
|
+
} else turn=session.turns.at(-1);
|
|
166
|
+
if(!turn)return null;
|
|
167
|
+
const id=hash(turn.token+'\0'+role),prior=session.events.find(value=>value.id===id);
|
|
168
|
+
if(prior) {
|
|
169
|
+
if(prior.hash!==hash(content)){session.issue='event_conflict';return session;}
|
|
170
|
+
return session;
|
|
171
|
+
}
|
|
172
|
+
if(state.sessions.reduce((count,item)=>count+item.events.length,0)>=MAX_EVENTS)fail('This capture scope reached 20,000 events. No queued event was discarded.');
|
|
173
|
+
session.events.push({id,sequence:session.next++,role,content,hash:hash(content),state:'queued',attempts:0,next_attempt_at:0});
|
|
174
|
+
if(role==='assistant')turn.closed=true;
|
|
175
|
+
return session;
|
|
176
|
+
}
|
|
177
|
+
async function flushOne(client,scope,sessionId,{explicit=false,timeout=1200}={}) {
|
|
178
|
+
const batch=await storage(scope,async(state,save)=>{
|
|
179
|
+
const session=state.sessions.find(value=>value.id===sessionId);
|
|
180
|
+
if(!session||state.paused||permanent(session.issue))return null;
|
|
181
|
+
let events=[];
|
|
182
|
+
for(const event of session.events.filter(event=>event.state==='queued')) {
|
|
183
|
+
if(!explicit&&(event.attempts>=3||event.next_attempt_at>Date.now()))break;
|
|
184
|
+
const next=[...events,{id:event.id,sequence:event.sequence,role:event.role,content:event.content}];
|
|
185
|
+
if(next.length>100||Buffer.byteLength(JSON.stringify({provider:scope.provider,session_id:session.remote_id,events:next}))>240*1024)break;
|
|
186
|
+
events=next;
|
|
187
|
+
}
|
|
188
|
+
if(!events.length)return null;
|
|
189
|
+
for(const item of events){const event=session.events.find(value=>value.id===item.id);event.attempts++;event.next_attempt_at=Date.now()+Math.min(60000,1000*2**Math.min(event.attempts,6));}
|
|
190
|
+
await save();
|
|
191
|
+
return {provider:scope.provider,session_id:session.remote_id,events};
|
|
192
|
+
});
|
|
193
|
+
if(!batch)return;
|
|
194
|
+
let receipt,issue;
|
|
195
|
+
try {receipt=validateCaptureReceipt(await client.request('/api/v1/sessions/events',{method:'POST',body:batch,timeout,maxResponseBytes:64*1024}),batch.events.length);}
|
|
196
|
+
catch(error){issue=safeIssue(error);}
|
|
197
|
+
await storage(scope,async(state,save)=>{
|
|
198
|
+
const session=state.sessions.find(value=>value.id===sessionId);
|
|
199
|
+
if(!session)return;
|
|
200
|
+
const events=batch.events.map(item=>session.events.find(value=>value.id===item.id&&value.sequence===item.sequence&&value.role===item.role&&value.hash===hash(item.content)));
|
|
201
|
+
if(events.some(event=>!event))fail('The queued capture changed while its request was in flight. Preserve the spool.');
|
|
202
|
+
if(receipt) {
|
|
203
|
+
if(!session.receipt||session.receipt.event_count<=receipt.event_count)session.receipt=receipt;
|
|
204
|
+
if(!permanent(session.issue))delete session.issue;
|
|
205
|
+
delete session.status_issue;session.status_verified_at=receipt.observed_at;
|
|
206
|
+
for(const event of events){event.state='acknowledged';delete event.content;}
|
|
207
|
+
} else if(events.some(event=>event.state==='queued')&&!permanent(session.issue))session.issue=issue;
|
|
208
|
+
await save();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function summary(state) {
|
|
213
|
+
return {configured:true,paused:state.paused,provider:state.scope.provider,project:state.scope.project,server:state.scope.server,
|
|
214
|
+
sessions:state.sessions.map(session=>({session_id:session.id,state:permanent(session.issue)?'blocked':session.events.some(event=>event.state==='queued')?'queued':session.status_issue?'verification_unavailable':session.receipt?'lexical_ready':'configured',queued_events:session.events.filter(event=>event.state==='queued').length,acknowledged_events:session.events.filter(event=>event.state==='acknowledged').length,issue:session.issue||null,current_verification:session.status_issue?{state:'unavailable',issue:session.status_issue}:session.status_verified_at?{state:'verified',observed_at:session.status_verified_at}:{state:'not_checked'},...(session.receipt?{last_acknowledgement:session.receipt}:{})}))};
|
|
215
|
+
}
|
|
216
|
+
export async function captureHook(input,options,client) {
|
|
217
|
+
const scope=await assertCaptureHook(input,options,client);
|
|
218
|
+
const sessionId=await storage(scope,async(state,save)=>{
|
|
219
|
+
const session=enqueue(state,input);await save();return session?.id;
|
|
220
|
+
});
|
|
221
|
+
if(sessionId) {
|
|
222
|
+
try {await verifyAgentIdentity(client,1200);await flushOne(client,scope,sessionId,{timeout:1200});}
|
|
223
|
+
catch(error){await storage(scope,async(state,save)=>{const session=state.sessions.find(value=>value.id===sessionId);if(session&&!permanent(session.issue)){session.issue=safeIssue(error);await save();}});}
|
|
224
|
+
}
|
|
225
|
+
return storage(scope,async state=>summary(state));
|
|
226
|
+
}
|
|
227
|
+
export async function assertCaptureHook(input,options,client) {
|
|
228
|
+
const scope=await projectScope(client,options.provider,options.project);
|
|
229
|
+
// Both the host's event cwd and executing hook must belong to this exact
|
|
230
|
+
// installed project. Never infer scope from transcript paths or file contents.
|
|
231
|
+
if(typeof input?.cwd!=='string'||await fs.realpath(input.cwd)!==scope.project||await fs.realpath(process.cwd())!==scope.project)fail('Hook project does not match the installed capture scope.');
|
|
232
|
+
await storage(scope,async()=>{}); // Require the explicit installed capture registration.
|
|
233
|
+
return scope;
|
|
234
|
+
}
|
|
235
|
+
export async function captureCommand(rest,options,client,{stdout=text=>process.stdout.write(text)}={}) {
|
|
236
|
+
const [command,...extra]=rest;
|
|
237
|
+
if(extra.length||!['status','flush','pause','resume'].includes(command))fail('Choose capture status, flush, pause, or resume.');
|
|
238
|
+
allowedOptions(options,['provider','project','timeout',...AGENT_OPTIONS],'capture');
|
|
239
|
+
if(options.timeout!==undefined&&command!=='flush')fail('--timeout is only supported by capture flush.');
|
|
240
|
+
const timeout=decimal(options.timeout,'timeout',1,60,10),scope=await projectScope(client,options.provider,options.project),deadline=Date.now()+timeout*1000;
|
|
241
|
+
if(command==='pause'||command==='resume') {
|
|
242
|
+
await storage(scope,async(state,save)=>{state.paused=command==='pause';await save();});
|
|
243
|
+
} else {
|
|
244
|
+
const snapshot=await storage(scope,async state=>({paused:state.paused,sessions:state.sessions.map(session=>({id:session.id,remote_id:session.remote_id,pending:session.events.some(event=>event.state==='queued'),blocked:permanent(session.issue)}))}));
|
|
245
|
+
try {await verifyAgentIdentity(client,Math.min(2000,timeout*1000));}
|
|
246
|
+
catch(error){
|
|
247
|
+
await storage(scope,async(state,save)=>{for(const session of state.sessions)session.status_issue=safeIssue(error);await save();});
|
|
248
|
+
if(command==='flush')throw error;
|
|
249
|
+
const value=await storage(scope,async state=>summary(state));
|
|
250
|
+
stdout(options.json?JSON.stringify(value,null,2)+'\n':'Current capture verification is unavailable. Previous acknowledgements are historical; check account access.\n');return 1;
|
|
251
|
+
}
|
|
252
|
+
if(command==='flush') {
|
|
253
|
+
if(snapshot.paused)fail('Capture is paused. Resume explicitly before flushing queued messages.');
|
|
254
|
+
let operations=0;
|
|
255
|
+
for(const session of snapshot.sessions) {
|
|
256
|
+
if(Date.now()>=deadline||operations>=10)break;
|
|
257
|
+
if(session.pending&&!session.blocked){await flushOne(client,scope,session.id,{explicit:true,timeout:Math.max(1,Math.min(2000,deadline-Date.now()))});operations++;}
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// Only query known captured sessions, with a shared finite deadline.
|
|
261
|
+
for(const session of snapshot.sessions.slice(-20)) {
|
|
262
|
+
if(Date.now()>=deadline)break;
|
|
263
|
+
let receipt,issue;
|
|
264
|
+
try {receipt=validateCaptureReceipt(await client.request('/api/v1/sessions/events/status?'+new URLSearchParams({provider:scope.provider,session_id:session.remote_id}),{timeout:Math.max(1,Math.min(1500,deadline-Date.now())),maxResponseBytes:64*1024}));}
|
|
265
|
+
catch(error){issue=error.status===410?'session_deleted':error.status===404?'not_received':safeIssue(error);}
|
|
266
|
+
await storage(scope,async(state,save)=>{const current=state.sessions.find(value=>value.id===session.id);if(current){if(receipt){if(!current.receipt||current.receipt.event_count<=receipt.event_count)current.receipt=receipt;current.status_verified_at=receipt.observed_at;delete current.status_issue;}else{current.status_issue=issue;if(issue==='session_deleted')current.issue=issue;}await save();}});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const result=await storage(scope,async state=>summary(state));
|
|
271
|
+
stdout(options.json?JSON.stringify(result,null,2)+'\n':`Capture ${result.paused?'paused':'configured'}: ${displayLine(result.provider)} in ${displayLine(result.project)}\n`+result.sessions.map(session=>`${displayLine(session.session_id)}: ${session.state}; ${session.queued_events} queued, ${session.acknowledged_events} acknowledged${session.issue?' ('+session.issue+')':''}${session.current_verification.issue?' (current verification: '+session.current_verification.issue+')':''}\n`).join(''));
|
|
272
|
+
return result.sessions.some(session=>session.state==='blocked'||session.current_verification.state==='unavailable'||command==='flush'&&session.queued_events)?1:0;
|
|
273
|
+
}
|