surf-cli 2.19.0 → 2.20.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 +169 -3
- package/dist/content/index.js +4 -4
- package/dist/content/index.js.map +1 -1
- package/dist/options/options.html +1 -18
- package/dist/service-worker/index.js +44 -15
- package/dist/service-worker/index.js.map +1 -1
- package/native/cli.cjs +109 -6
- package/native/do-executor.cjs +35 -8
- package/native/doctor.cjs +200 -40
- package/native/host-helpers.cjs +95 -10
- package/native/host.cjs +24 -2
- package/native/native-host-launch-probe.cjs +69 -0
- package/native/private-state.cjs +25 -1
- package/native/semantic-cli.cjs +764 -0
- package/native/semantic-core.cjs +369 -0
- package/native/semantic-credentials.cjs +207 -0
- package/native/semantic-provider.cjs +61 -0
- package/native/semantic-workflow-executor.cjs +65 -0
- package/native/semantic-workflow-state.cjs +271 -0
- package/native/semantic-workflow.cjs +398 -0
- package/native/tool-scope.cjs +1 -0
- package/native/workflow-definition.cjs +125 -1
- package/native/workflow-runtime.cjs +71 -5
- package/package.json +8 -4
- package/scripts/install-native-host.cjs +103 -60
- package/scripts/uninstall-native-host.cjs +40 -38
- package/scripts/windows-interop.cjs +89 -0
- package/skills/surf/SKILL.md +78 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
const { Buffer } = require("node:buffer");
|
|
2
|
+
|
|
3
|
+
const SEMANTIC_POLICY = Object.freeze({
|
|
4
|
+
model: "jev-1.13.0",
|
|
5
|
+
timeoutMs: 5_000,
|
|
6
|
+
probabilitySumTolerance: 0.01,
|
|
7
|
+
thresholds: Object.freeze({ find: 0.7, filter: 0.65, verifyPositive: 0.85, verifyNegative: 0.85, prerequisiteSupported: 0.75, prerequisiteBlocked: 0.9, write: 0.95, exactRefWrite: 0.65 }),
|
|
8
|
+
limits: Object.freeze({
|
|
9
|
+
stateBytes: 24 * 1024,
|
|
10
|
+
candidates: 64,
|
|
11
|
+
actionChoices: 70, // Six fixed controls plus one action for every observed candidate.
|
|
12
|
+
chunks: 48,
|
|
13
|
+
questions: 50,
|
|
14
|
+
filterTop: 12,
|
|
15
|
+
inputSlots: 16,
|
|
16
|
+
inputValueBytes: 16 * 1024,
|
|
17
|
+
defaultSteps: 5,
|
|
18
|
+
maxSteps: 8,
|
|
19
|
+
defaultWallMs: 30_000,
|
|
20
|
+
maxWallMs: 60_000,
|
|
21
|
+
providerCalls: 17,
|
|
22
|
+
invalidActionDecisionRetries: 1,
|
|
23
|
+
directActionRetryChoices: 18,
|
|
24
|
+
invalidActionDecisionRegionTop: 2,
|
|
25
|
+
staleRefreshes: 2,
|
|
26
|
+
identicalObservationHashes: 2,
|
|
27
|
+
}),
|
|
28
|
+
waitsMs: Object.freeze([500, 1_500]),
|
|
29
|
+
scrolls: Object.freeze(["down_600", "up_600", "top", "bottom"]),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
class SemanticError extends Error {
|
|
33
|
+
constructor(code, message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "SemanticError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fail(message) {
|
|
41
|
+
throw new SemanticError("semantic_invalid_request", message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function providerInvalid(message) {
|
|
45
|
+
throw new SemanticError("provider_invalid_response", message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertOpaqueId(value, field) {
|
|
49
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/.test(value)) {
|
|
50
|
+
fail(`${field} must be a bounded opaque identifier`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertUniqueItems(items, maximum, label) {
|
|
55
|
+
if (!Array.isArray(items) || items.length > maximum) fail(`${label} exceeds its limit of ${maximum}`);
|
|
56
|
+
const ids = new Set();
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) fail(`${label} entries must be objects`);
|
|
59
|
+
assertOpaqueId(item.id, `${label} id`);
|
|
60
|
+
if (ids.has(item.id)) fail(`${label} ids must be unique`);
|
|
61
|
+
ids.add(item.id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateState(state) {
|
|
66
|
+
let encoded;
|
|
67
|
+
try {
|
|
68
|
+
encoded = JSON.stringify(state);
|
|
69
|
+
} catch {
|
|
70
|
+
fail("semantic state must be JSON serializable");
|
|
71
|
+
}
|
|
72
|
+
if (encoded === undefined) fail("semantic state must be JSON serializable");
|
|
73
|
+
if (Buffer.byteLength(encoded, "utf8") > SEMANTIC_POLICY.limits.stateBytes) {
|
|
74
|
+
fail(`semantic state exceeds ${SEMANTIC_POLICY.limits.stateBytes} UTF-8 bytes`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateGoal(goal) {
|
|
79
|
+
if (typeof goal !== "string" || !goal.trim()) fail("goal must be a nonblank string");
|
|
80
|
+
if (Buffer.byteLength(goal, "utf8") > 4_096) fail("goal exceeds 4096 UTF-8 bytes");
|
|
81
|
+
return goal.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exactKeys(value, expected, label) {
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
86
|
+
providerInvalid(`${label} must be an object`);
|
|
87
|
+
}
|
|
88
|
+
const actual = Object.keys(value).sort();
|
|
89
|
+
const wanted = [...expected].sort();
|
|
90
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
|
91
|
+
providerInvalid(`${label} keys do not match the request`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateMetadata(response) {
|
|
96
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
97
|
+
providerInvalid("provider response must be an object");
|
|
98
|
+
}
|
|
99
|
+
exactKeys(response, ["answers", "model", "usage"], "provider response");
|
|
100
|
+
if (typeof response.model !== "string" || !response.model.trim()) providerInvalid("provider model is missing");
|
|
101
|
+
exactKeys(response.usage, ["input_tokens", "output_tokens"], "provider usage");
|
|
102
|
+
for (const key of ["input_tokens", "output_tokens"]) {
|
|
103
|
+
if (!Number.isSafeInteger(response.usage[key]) || response.usage[key] < 0) {
|
|
104
|
+
providerInvalid("provider usage must contain nonnegative integer token counts");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { model: response.model, usage: { ...response.usage } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateChoice(answer, labels) {
|
|
111
|
+
exactKeys(answer, ["choice", "confidence", "probabilities", "type"], "choice answer");
|
|
112
|
+
if (answer.type !== "choice") providerInvalid("provider answer must be a choice");
|
|
113
|
+
if (!labels.includes(answer.choice)) providerInvalid("provider selected an unknown label");
|
|
114
|
+
if (!Number.isFinite(answer.confidence) || answer.confidence < 0 || answer.confidence > 1) {
|
|
115
|
+
providerInvalid("provider confidence must be between zero and one");
|
|
116
|
+
}
|
|
117
|
+
exactKeys(answer.probabilities, labels, "choice probabilities");
|
|
118
|
+
let sum = 0;
|
|
119
|
+
let maximum = -1;
|
|
120
|
+
for (const label of labels) {
|
|
121
|
+
const probability = answer.probabilities[label];
|
|
122
|
+
if (!Number.isFinite(probability) || probability < 0 || probability > 1) {
|
|
123
|
+
providerInvalid("provider probabilities must be finite values between zero and one");
|
|
124
|
+
}
|
|
125
|
+
sum += probability;
|
|
126
|
+
maximum = Math.max(maximum, probability);
|
|
127
|
+
}
|
|
128
|
+
if (Math.abs(sum - 1) > SEMANTIC_POLICY.probabilitySumTolerance) {
|
|
129
|
+
providerInvalid("provider probabilities do not sum to one");
|
|
130
|
+
}
|
|
131
|
+
if (answer.probabilities[answer.choice] + Number.EPSILON < maximum) {
|
|
132
|
+
providerInvalid("provider selected label is inconsistent with its probabilities");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
label: answer.choice,
|
|
136
|
+
probability: answer.probabilities[answer.choice],
|
|
137
|
+
probabilities: { ...answer.probabilities },
|
|
138
|
+
confidence: answer.confidence,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function choiceQuestion(instructions, labels) {
|
|
143
|
+
return { type: "choice", instructions, criteria: Object.fromEntries(labels.map((label) => [label, null])) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function evaluatedChoices({ state, questions, evaluate }) {
|
|
147
|
+
validateState(state);
|
|
148
|
+
if (typeof evaluate !== "function") fail("evaluate must be a function");
|
|
149
|
+
const names = Object.keys(questions);
|
|
150
|
+
if (!names.length || names.length > SEMANTIC_POLICY.limits.questions) fail("question count is outside policy limits");
|
|
151
|
+
const response = await evaluate(state, questions, {});
|
|
152
|
+
const metadata = validateMetadata(response);
|
|
153
|
+
exactKeys(response.answers, names, "provider answers");
|
|
154
|
+
const decisions = {};
|
|
155
|
+
for (const name of names) decisions[name] = validateChoice(response.answers[name], Object.keys(questions[name].criteria));
|
|
156
|
+
return { decisions, ...metadata };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function candidateDescription(candidate) {
|
|
160
|
+
const parts = [candidate.role, candidate.name, candidate.text].filter((value) => typeof value === "string" && value);
|
|
161
|
+
return parts.join(" | ").slice(0, 1_024) || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function actionDescription(action, state) {
|
|
165
|
+
const ref = action.ref || action.concreteRef;
|
|
166
|
+
const candidate = ref && Array.isArray(state.candidates)
|
|
167
|
+
? state.candidates.find((item) => item.id === ref)
|
|
168
|
+
: null;
|
|
169
|
+
const parts = [action.kind, candidate?.role, candidate?.name];
|
|
170
|
+
if (candidate?.state?.checked === true) parts.push("checked");
|
|
171
|
+
if (candidate?.state?.checked === false) parts.push("unchecked");
|
|
172
|
+
if (candidate?.state?.selected === true) parts.push("selected");
|
|
173
|
+
if (candidate?.state?.selected === false) parts.push("not selected");
|
|
174
|
+
if (action.kind === "scroll") parts.push(action.direction);
|
|
175
|
+
if (action.kind === "wait") parts.push(`${action.durationMs}ms`);
|
|
176
|
+
return parts.filter((value) => value !== undefined && value !== "").join(" | ").slice(0, 1_024) || null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function find({ state, goal, candidates, thresholds = {}, evaluate }) {
|
|
180
|
+
goal = validateGoal(goal);
|
|
181
|
+
assertUniqueItems(candidates, SEMANTIC_POLICY.limits.candidates, "candidates");
|
|
182
|
+
const labels = [...candidates.map((candidate) => candidate.id), "none"];
|
|
183
|
+
const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, candidateDescription(candidate)]));
|
|
184
|
+
criteria.none = "No supplied candidate matches the goal";
|
|
185
|
+
const response = await evaluatedChoices({
|
|
186
|
+
state,
|
|
187
|
+
questions: { target: { type: "choice", instructions: `Select the supplied candidate that matches this goal: ${goal}`, criteria } },
|
|
188
|
+
evaluate,
|
|
189
|
+
});
|
|
190
|
+
const decision = response.decisions.target;
|
|
191
|
+
const appliedThreshold = thresholds.find ?? SEMANTIC_POLICY.thresholds.find;
|
|
192
|
+
const found = decision.label !== "none" && decision.probability >= appliedThreshold;
|
|
193
|
+
return {
|
|
194
|
+
status: found ? "found" : "uncertain",
|
|
195
|
+
candidate: found ? candidates.find((item) => item.id === decision.label) : null,
|
|
196
|
+
appliedThreshold,
|
|
197
|
+
decision,
|
|
198
|
+
model: response.model,
|
|
199
|
+
usage: response.usage,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function verify({ state, outcome, evidence = [], thresholds = {}, evaluate }) {
|
|
204
|
+
outcome = validateGoal(outcome);
|
|
205
|
+
assertUniqueItems(evidence, SEMANTIC_POLICY.limits.chunks, "evidence");
|
|
206
|
+
const questions = {
|
|
207
|
+
verdict: choiceQuestion(`Does the supplied page state show this outcome: ${outcome}`, ["satisfied", "not_satisfied"]),
|
|
208
|
+
};
|
|
209
|
+
if (evidence.length) {
|
|
210
|
+
questions.evidence = choiceQuestion("Select the supplied evidence ID most relevant to the verdict", [
|
|
211
|
+
...evidence.map((item) => item.id),
|
|
212
|
+
"none",
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
const response = await evaluatedChoices({ state, questions, evaluate });
|
|
216
|
+
const verdict = response.decisions.verdict;
|
|
217
|
+
const verifyPositive = thresholds.verifyPositive ?? SEMANTIC_POLICY.thresholds.verifyPositive;
|
|
218
|
+
const verifyNegative = thresholds.verifyNegative ?? SEMANTIC_POLICY.thresholds.verifyNegative;
|
|
219
|
+
const appliedThreshold = verdict.label === "satisfied" ? verifyPositive : verifyNegative;
|
|
220
|
+
let status = "uncertain";
|
|
221
|
+
if (verdict.label === "satisfied" && verdict.probability >= verifyPositive) status = "satisfied";
|
|
222
|
+
if (verdict.label === "not_satisfied" && verdict.probability >= verifyNegative) status = "not_satisfied";
|
|
223
|
+
const evidenceDecision = response.decisions.evidence;
|
|
224
|
+
const evidenceItem = evidenceDecision && evidenceDecision.label !== "none"
|
|
225
|
+
? evidence.find((item) => item.id === evidenceDecision.label)
|
|
226
|
+
: null;
|
|
227
|
+
return { status, appliedThreshold, decision: verdict, evidence: evidenceItem || null, evidenceDecision: evidenceDecision || null, model: response.model, usage: response.usage };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function filter({ state, goal, chunks, top = SEMANTIC_POLICY.limits.filterTop, thresholds = {}, evaluate }) {
|
|
231
|
+
goal = validateGoal(goal);
|
|
232
|
+
assertUniqueItems(chunks, SEMANTIC_POLICY.limits.chunks, "chunks");
|
|
233
|
+
if (!Number.isInteger(top) || top < 1 || top > SEMANTIC_POLICY.limits.filterTop) fail(`top must be between 1 and ${SEMANTIC_POLICY.limits.filterTop}`);
|
|
234
|
+
const appliedThreshold = thresholds.filter ?? SEMANTIC_POLICY.thresholds.filter;
|
|
235
|
+
if (!chunks.length) return { status: "uncertain", appliedThreshold, chunks: [], omittedCount: 0, decisions: {}, model: null, usage: null };
|
|
236
|
+
const questions = Object.fromEntries(chunks.map((chunk, index) => [
|
|
237
|
+
`chunk_${index}`,
|
|
238
|
+
choiceQuestion(`Is chunk ${chunk.id} relevant to this goal: ${goal}`, ["relevant", "not_relevant"]),
|
|
239
|
+
]));
|
|
240
|
+
const response = await evaluatedChoices({ state, questions, evaluate });
|
|
241
|
+
const ranked = chunks
|
|
242
|
+
.map((chunk, index) => ({ chunk, decision: response.decisions[`chunk_${index}`], index }))
|
|
243
|
+
.filter((entry) => entry.decision.label === "relevant" && entry.decision.probability >= appliedThreshold)
|
|
244
|
+
.sort((left, right) => right.decision.probability - left.decision.probability || left.index - right.index)
|
|
245
|
+
.slice(0, top);
|
|
246
|
+
return {
|
|
247
|
+
status: ranked.length ? "filtered" : "uncertain",
|
|
248
|
+
appliedThreshold,
|
|
249
|
+
chunks: ranked.map(({ chunk, decision }) => ({ ...chunk, relevance: decision })),
|
|
250
|
+
omittedCount: chunks.length - ranked.length,
|
|
251
|
+
decisions: response.decisions,
|
|
252
|
+
model: response.model,
|
|
253
|
+
usage: response.usage,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function validateAction(action, options) {
|
|
258
|
+
const allowedKinds = ["navigate", "click", "scroll", "wait", "fill"];
|
|
259
|
+
if (!allowedKinds.includes(action.kind)) fail(`unsupported action kind for ${action.id}`);
|
|
260
|
+
if (action.kind === "wait" && !SEMANTIC_POLICY.waitsMs.includes(action.durationMs)) fail(`invalid wait action ${action.id}`);
|
|
261
|
+
if (action.kind === "scroll" && !SEMANTIC_POLICY.scrolls.includes(action.direction)) fail(`invalid scroll action ${action.id}`);
|
|
262
|
+
if (action.kind === "navigate") {
|
|
263
|
+
let url;
|
|
264
|
+
try { url = new URL(action.url); } catch { fail(`invalid navigation URL for ${action.id}`); }
|
|
265
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.origin !== options.origin) fail(`navigation ${action.id} is not a safe same-origin HTTP(S) URL`);
|
|
266
|
+
}
|
|
267
|
+
if (action.kind === "click" || action.kind === "fill") {
|
|
268
|
+
assertOpaqueId(action.ref, `action ${action.id} ref`);
|
|
269
|
+
if (!options.allowWrite) return false;
|
|
270
|
+
if (options.allowRefs?.length && !options.allowRefs.includes(action.ref)) return false;
|
|
271
|
+
}
|
|
272
|
+
if (action.kind === "fill") {
|
|
273
|
+
assertOpaqueId(action.slot, `action ${action.id} slot`);
|
|
274
|
+
if (!options.inputSlots.includes(action.slot)) fail(`fill action ${action.id} references an unknown input slot`);
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function chooseAction({ state, goal, actions, origin, allowWrite = false, allowRefs = [], inputSlots = [], thresholds = {}, evaluate }) {
|
|
280
|
+
goal = validateGoal(goal);
|
|
281
|
+
assertUniqueItems(actions, SEMANTIC_POLICY.limits.actionChoices, "actions");
|
|
282
|
+
if (!Array.isArray(allowRefs)) fail("allowRefs must be an array");
|
|
283
|
+
if (!Array.isArray(inputSlots) || inputSlots.length > SEMANTIC_POLICY.limits.inputSlots) fail("inputSlots exceeds policy limits");
|
|
284
|
+
for (const value of [...allowRefs, ...inputSlots]) assertOpaqueId(value, "authorization identifier");
|
|
285
|
+
const eligible = actions.filter((action) => validateAction(action, { origin, allowWrite, allowRefs, inputSlots }));
|
|
286
|
+
const labels = [...eligible.map((action) => action.id), "stop"];
|
|
287
|
+
const actionCriteria = Object.fromEntries(eligible.map((action) => [action.id, actionDescription(action, state)]));
|
|
288
|
+
actionCriteria.stop = "The goal is already satisfied, or no supplied action can safely make progress";
|
|
289
|
+
const writeActions = eligible.filter((item) => item.kind === "click" || item.kind === "fill");
|
|
290
|
+
const questions = {
|
|
291
|
+
action: {
|
|
292
|
+
type: "choice",
|
|
293
|
+
instructions: `Choose the single next supplied action that safely advances this multi-step goal: ${goal}. The action only needs to make progress. Choose stop only if the goal is already satisfied or no supplied action can safely make progress.`,
|
|
294
|
+
criteria: actionCriteria,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
const evidence = writeActions.length && Array.isArray(state.chunks) ? state.chunks : [];
|
|
298
|
+
if (writeActions.length) {
|
|
299
|
+
assertUniqueItems(evidence, SEMANTIC_POLICY.limits.chunks, "chunks");
|
|
300
|
+
questions.prerequisites = {
|
|
301
|
+
type: "choice",
|
|
302
|
+
instructions: `Before any mutation, determine whether the supplied page state explicitly supports every product or variant prerequisite in this goal: ${goal}`,
|
|
303
|
+
criteria: {
|
|
304
|
+
supported: "Every explicit product and variant prerequisite is already satisfied or visibly available through supplied controls",
|
|
305
|
+
blocked: "At least one explicit product or variant prerequisite is contradicted or absent from the supplied page state",
|
|
306
|
+
uncertain: "The supplied page state does not establish whether all explicit prerequisites are supported",
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
questions.prerequisite_evidence = choiceQuestion(
|
|
310
|
+
"Select the supplied page region most relevant to the prerequisite verdict",
|
|
311
|
+
[...evidence.map((chunk) => chunk.id), "none"],
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const response = await evaluatedChoices({
|
|
315
|
+
state,
|
|
316
|
+
questions,
|
|
317
|
+
evaluate,
|
|
318
|
+
});
|
|
319
|
+
const decision = response.decisions.action;
|
|
320
|
+
const prerequisiteDecision = response.decisions.prerequisites || null;
|
|
321
|
+
const prerequisiteEvidenceDecision = response.decisions.prerequisite_evidence || null;
|
|
322
|
+
const prerequisiteSupported = thresholds.prerequisiteSupported ?? SEMANTIC_POLICY.thresholds.prerequisiteSupported;
|
|
323
|
+
const prerequisiteBlocked = thresholds.prerequisiteBlocked ?? SEMANTIC_POLICY.thresholds.prerequisiteBlocked;
|
|
324
|
+
let prerequisiteStatus = "not_applicable";
|
|
325
|
+
if (writeActions.length) {
|
|
326
|
+
prerequisiteStatus = "uncertain";
|
|
327
|
+
if (prerequisiteDecision.label === "supported" && prerequisiteDecision.probability >= prerequisiteSupported) prerequisiteStatus = "supported";
|
|
328
|
+
if (prerequisiteDecision.label === "blocked" && prerequisiteDecision.probability >= prerequisiteBlocked) prerequisiteStatus = "blocked";
|
|
329
|
+
}
|
|
330
|
+
const prerequisiteEvidenceThreshold = SEMANTIC_POLICY.thresholds.filter;
|
|
331
|
+
const prerequisiteEvidence = !prerequisiteEvidenceDecision || prerequisiteEvidenceDecision.label === "none" || prerequisiteEvidenceDecision.probability < prerequisiteEvidenceThreshold
|
|
332
|
+
? null
|
|
333
|
+
: evidence.find((chunk) => chunk.id === prerequisiteEvidenceDecision.label) || null;
|
|
334
|
+
const action = eligible.find((item) => item.id === decision.label) || null;
|
|
335
|
+
const exactRefWrite = action && (action.kind === "click" || action.kind === "fill") &&
|
|
336
|
+
allowRefs.length === 1 && writeActions.length === 1 && writeActions[0].ref === allowRefs[0];
|
|
337
|
+
const appliedThreshold = action && (action.kind === "click" || action.kind === "fill")
|
|
338
|
+
? exactRefWrite ? thresholds.exactRefWrite ?? SEMANTIC_POLICY.thresholds.exactRefWrite : thresholds.write ?? SEMANTIC_POLICY.thresholds.write
|
|
339
|
+
: thresholds.find ?? SEMANTIC_POLICY.thresholds.find;
|
|
340
|
+
const write = action && (action.kind === "click" || action.kind === "fill");
|
|
341
|
+
const blocked = prerequisiteStatus === "blocked" && (!action || write);
|
|
342
|
+
const selected = action && decision.probability >= appliedThreshold && (!write || prerequisiteStatus === "supported") ? action : null;
|
|
343
|
+
return {
|
|
344
|
+
status: blocked ? "blocked" : selected ? "selected" : "uncertain",
|
|
345
|
+
action: selected,
|
|
346
|
+
appliedThreshold,
|
|
347
|
+
decision,
|
|
348
|
+
logicalDecision: action ? {
|
|
349
|
+
id: action.id,
|
|
350
|
+
identity: action.logicalIdentity || action.id,
|
|
351
|
+
probability: decision.probability,
|
|
352
|
+
} : null,
|
|
353
|
+
concreteDecision: action ? {
|
|
354
|
+
id: action.id,
|
|
355
|
+
...(action.ref || action.concreteRef ? { ref: action.ref || action.concreteRef } : {}),
|
|
356
|
+
probability: decision.probability,
|
|
357
|
+
} : null,
|
|
358
|
+
prerequisiteStatus,
|
|
359
|
+
prerequisiteThresholds: { supported: prerequisiteSupported, blocked: prerequisiteBlocked },
|
|
360
|
+
prerequisiteDecision,
|
|
361
|
+
prerequisiteEvidence,
|
|
362
|
+
prerequisiteEvidenceThreshold,
|
|
363
|
+
prerequisiteEvidenceDecision,
|
|
364
|
+
model: response.model,
|
|
365
|
+
usage: response.usage,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = { SEMANTIC_POLICY, SemanticError, chooseAction, filter, find, verify };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const {
|
|
5
|
+
atomicWriteJson,
|
|
6
|
+
readPrivateJson,
|
|
7
|
+
removePrivateFile,
|
|
8
|
+
} = require("./private-state.cjs");
|
|
9
|
+
|
|
10
|
+
const CREDENTIAL_VERSION = 1;
|
|
11
|
+
const MAX_API_KEY_BYTES = 16 * 1024;
|
|
12
|
+
const FINGERPRINT_LENGTH = 12;
|
|
13
|
+
const TTY_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
14
|
+
|
|
15
|
+
function credentialLocation(env = process.env, { platform = process.platform, homeDir = os.homedir() } = {}) {
|
|
16
|
+
const windows = platform === "win32";
|
|
17
|
+
const pathApi = windows ? path.win32 : path;
|
|
18
|
+
const configRoot = windows
|
|
19
|
+
? (typeof env.APPDATA === "string" && env.APPDATA.trim() ? env.APPDATA.trim() : pathApi.join(homeDir, "AppData", "Roaming"))
|
|
20
|
+
: (typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() ? env.XDG_CONFIG_HOME.trim() : pathApi.join(homeDir, ".config"));
|
|
21
|
+
const root = pathApi.join(configRoot, windows ? "TypeSafe" : "typesafe");
|
|
22
|
+
return {
|
|
23
|
+
root,
|
|
24
|
+
filePath: pathApi.join(root, "credentials.json"),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireApiKey(value) {
|
|
29
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
30
|
+
throw new Error("TypeSafe API key must not be blank");
|
|
31
|
+
}
|
|
32
|
+
if (Buffer.byteLength(value, "utf8") > MAX_API_KEY_BYTES) {
|
|
33
|
+
throw new Error(`TypeSafe API key exceeds ${MAX_API_KEY_BYTES} bytes`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fingerprintApiKey(apiKey) {
|
|
39
|
+
const digest = crypto.createHash("sha256").update(apiKey, "utf8").digest("hex");
|
|
40
|
+
return `sha256:${digest.slice(0, FINGERPRINT_LENGTH)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readStoredApiKey(env = process.env) {
|
|
44
|
+
const { root, filePath } = credentialLocation(env);
|
|
45
|
+
const value = readPrivateJson(filePath, null, { root });
|
|
46
|
+
if (value === null) return null;
|
|
47
|
+
if (
|
|
48
|
+
!value ||
|
|
49
|
+
typeof value !== "object" ||
|
|
50
|
+
Array.isArray(value) ||
|
|
51
|
+
value.version !== CREDENTIAL_VERSION ||
|
|
52
|
+
typeof value.apiKey !== "string"
|
|
53
|
+
) {
|
|
54
|
+
throw new Error("stored TypeSafe credential is invalid");
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return requireApiKey(value.apiKey);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error("stored TypeSafe credential is invalid");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveTypeSafeCredential(env = process.env) {
|
|
64
|
+
if (typeof env.TYPESAFE_API_KEY === "string" && env.TYPESAFE_API_KEY.trim().length > 0) {
|
|
65
|
+
const apiKey = requireApiKey(env.TYPESAFE_API_KEY);
|
|
66
|
+
return { apiKey, source: "environment", fingerprint: fingerprintApiKey(apiKey) };
|
|
67
|
+
}
|
|
68
|
+
const apiKey = readStoredApiKey(env);
|
|
69
|
+
if (apiKey === null) return null;
|
|
70
|
+
return { apiKey, source: "shared-store", fingerprint: fingerprintApiKey(apiKey) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function credentialStatus(env = process.env) {
|
|
74
|
+
const credential = resolveTypeSafeCredential(env);
|
|
75
|
+
if (!credential) return { source: "not-configured" };
|
|
76
|
+
return { source: credential.source, fingerprint: credential.fingerprint };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function storeTypeSafeCredential(apiKey, env = process.env) {
|
|
80
|
+
const validated = requireApiKey(apiKey);
|
|
81
|
+
const { root, filePath } = credentialLocation(env);
|
|
82
|
+
atomicWriteJson(filePath, { version: CREDENTIAL_VERSION, apiKey: validated }, { root });
|
|
83
|
+
return { source: "shared-store", fingerprint: fingerprintApiKey(validated) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clearStoredTypeSafeCredential(env = process.env) {
|
|
87
|
+
const { root, filePath } = credentialLocation(env);
|
|
88
|
+
return removePrivateFile(filePath, { root });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readNonInteractiveLine(input) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let bytes = 0;
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
input.off("data", onData);
|
|
97
|
+
input.off("end", onEnd);
|
|
98
|
+
input.off("error", onError);
|
|
99
|
+
};
|
|
100
|
+
const fail = (error) => {
|
|
101
|
+
cleanup();
|
|
102
|
+
reject(error);
|
|
103
|
+
};
|
|
104
|
+
const onData = (chunk) => {
|
|
105
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
106
|
+
bytes += buffer.length;
|
|
107
|
+
if (bytes > MAX_API_KEY_BYTES + 2) {
|
|
108
|
+
input.pause?.();
|
|
109
|
+
fail(new Error(`TypeSafe API key input exceeds ${MAX_API_KEY_BYTES} bytes`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
chunks.push(buffer);
|
|
113
|
+
};
|
|
114
|
+
const onEnd = () => {
|
|
115
|
+
cleanup();
|
|
116
|
+
let value = Buffer.concat(chunks).toString("utf8");
|
|
117
|
+
if (value.endsWith("\n")) value = value.slice(0, -1);
|
|
118
|
+
if (value.endsWith("\r")) value = value.slice(0, -1);
|
|
119
|
+
if (value.includes("\n") || value.includes("\r")) {
|
|
120
|
+
reject(new Error("TypeSafe API key input must be one line"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
resolve(requireApiKey(value));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
reject(error);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const onError = (error) => fail(error);
|
|
130
|
+
input.on("data", onData);
|
|
131
|
+
input.once("end", onEnd);
|
|
132
|
+
input.once("error", onError);
|
|
133
|
+
input.resume?.();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readHiddenTtyLine(input, output, signalSource = process) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
let value = "";
|
|
140
|
+
let settled = false;
|
|
141
|
+
const previousRaw = input.isRaw === true;
|
|
142
|
+
const finish = (error) => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
input.off("data", onData);
|
|
146
|
+
input.off("error", onError);
|
|
147
|
+
for (const signal of TTY_SIGNALS) signalSource.off(signal, signalHandlers[signal]);
|
|
148
|
+
try { input.setRawMode(previousRaw); } catch {}
|
|
149
|
+
input.pause?.();
|
|
150
|
+
output.write("\n");
|
|
151
|
+
if (error) reject(error);
|
|
152
|
+
else {
|
|
153
|
+
try { resolve(requireApiKey(value)); } catch (validationError) { reject(validationError); }
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const signalHandlers = Object.fromEntries(
|
|
157
|
+
TTY_SIGNALS.map((signal) => [signal, () => finish(new Error(`TypeSafe API key input interrupted by ${signal}`))]),
|
|
158
|
+
);
|
|
159
|
+
const onError = (error) => finish(error);
|
|
160
|
+
const onData = (chunk) => {
|
|
161
|
+
for (const character of chunk.toString("utf8")) {
|
|
162
|
+
if (character === "\u0003") return finish(new Error("TypeSafe API key input cancelled"));
|
|
163
|
+
if (character === "\r" || character === "\n") return finish();
|
|
164
|
+
if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
|
|
165
|
+
else value += character;
|
|
166
|
+
if (Buffer.byteLength(value, "utf8") > MAX_API_KEY_BYTES) {
|
|
167
|
+
return finish(new Error(`TypeSafe API key input exceeds ${MAX_API_KEY_BYTES} bytes`));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
output.write("TypeSafe API key: ");
|
|
172
|
+
input.setRawMode(true);
|
|
173
|
+
input.on("data", onData);
|
|
174
|
+
input.once("error", onError);
|
|
175
|
+
for (const signal of TTY_SIGNALS) signalSource.once(signal, signalHandlers[signal]);
|
|
176
|
+
input.resume?.();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readTypeSafeApiKey(options = {}) {
|
|
181
|
+
const { input = process.stdin, output = process.stderr, signalSource = process } = options;
|
|
182
|
+
if (input.isTTY) {
|
|
183
|
+
if (typeof input.setRawMode !== "function") {
|
|
184
|
+
return Promise.reject(new Error("hidden TypeSafe API key input is unavailable on this terminal"));
|
|
185
|
+
}
|
|
186
|
+
return readHiddenTtyLine(input, output, signalSource);
|
|
187
|
+
}
|
|
188
|
+
return readNonInteractiveLine(input);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function setTypeSafeCredentialFromInput(options = {}) {
|
|
192
|
+
const apiKey = await readTypeSafeApiKey(options);
|
|
193
|
+
return storeTypeSafeCredential(apiKey, options.env || process.env);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
MAX_API_KEY_BYTES,
|
|
198
|
+
clearStoredTypeSafeCredential,
|
|
199
|
+
credentialLocation,
|
|
200
|
+
credentialStatus,
|
|
201
|
+
fingerprintApiKey,
|
|
202
|
+
readStoredApiKey,
|
|
203
|
+
readTypeSafeApiKey,
|
|
204
|
+
resolveTypeSafeCredential,
|
|
205
|
+
setTypeSafeCredentialFromInput,
|
|
206
|
+
storeTypeSafeCredential,
|
|
207
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { SEMANTIC_POLICY, SemanticError } = require("./semantic-core.cjs");
|
|
2
|
+
|
|
3
|
+
const BASE_URL = "https://api.typesafe.ai";
|
|
4
|
+
|
|
5
|
+
function providerError(code, message, status) {
|
|
6
|
+
const error = new SemanticError(code, message);
|
|
7
|
+
if (status !== undefined) error.status = status;
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function mapProviderError(error) {
|
|
12
|
+
const status = Number.isInteger(error?.status) ? error.status : undefined;
|
|
13
|
+
if (status === 401 || status === 403) return providerError("provider_authentication", "TypeSafe authentication failed", status);
|
|
14
|
+
if (status === 400 || status === 404 || status === 422) return providerError("provider_invalid_request", "TypeSafe rejected the semantic request", status);
|
|
15
|
+
if (status === 429) return providerError("provider_rate_limited", "TypeSafe rate limit exceeded", status);
|
|
16
|
+
if (status === 529 || (status !== undefined && status >= 500)) return providerError("provider_unavailable", "TypeSafe is unavailable", status);
|
|
17
|
+
if (error?.name === "APITimeoutError") return providerError("provider_timeout", "TypeSafe request timed out");
|
|
18
|
+
if (error?.name === "APIUserAbortError" || error?.name === "AbortError") return providerError("provider_cancelled", "TypeSafe request was cancelled");
|
|
19
|
+
if (error?.name === "APIConnectionError") return providerError("provider_unavailable", "TypeSafe connection failed");
|
|
20
|
+
return providerError("provider_error", "TypeSafe request failed");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveModel(env) {
|
|
24
|
+
const override = env.SURF_JEV_MODEL;
|
|
25
|
+
return typeof override === "string" && override.trim() ? override.trim() : SEMANTIC_POLICY.model;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createJevEvaluator({ apiKey, env = process.env, loadSdk = () => require("@typesafe-ai/sdk"), fetch } = {}) {
|
|
29
|
+
if (typeof apiKey !== "string" || !apiKey.trim()) {
|
|
30
|
+
throw providerError("provider_not_configured", "TypeSafe API key is not configured");
|
|
31
|
+
}
|
|
32
|
+
const model = resolveModel(env);
|
|
33
|
+
let client;
|
|
34
|
+
|
|
35
|
+
return async function evaluate(state, questions, options = {}) {
|
|
36
|
+
try {
|
|
37
|
+
if (!client) {
|
|
38
|
+
const sdk = loadSdk();
|
|
39
|
+
if (!sdk || typeof sdk.TypeSafeClient !== "function") throw new Error("invalid SDK module");
|
|
40
|
+
client = new sdk.TypeSafeClient({
|
|
41
|
+
apiKey: apiKey.trim(),
|
|
42
|
+
baseURL: BASE_URL,
|
|
43
|
+
defaultModel: model,
|
|
44
|
+
logLevel: "off",
|
|
45
|
+
retry: { maxRetries: 0 },
|
|
46
|
+
timeout: SEMANTIC_POLICY.timeoutMs,
|
|
47
|
+
...(fetch ? { fetch } : {}),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return await client.systemOne(
|
|
51
|
+
{ state, questions, model },
|
|
52
|
+
{ signal: options.signal, timeout: SEMANTIC_POLICY.timeoutMs, retry: { maxRetries: 0 } },
|
|
53
|
+
);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof SemanticError) throw error;
|
|
56
|
+
throw mapProviderError(error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { BASE_URL, createJevEvaluator, mapProviderError, resolveModel };
|