@ruakit/schema 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/NOTICE +4 -0
- package/dist/decision.d.ts +706 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +3 -0
- package/dist/decision.js.map +7 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +7 -0
- package/dist/plan.d.ts +1126 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +3 -0
- package/dist/plan.js.map +7 -0
- package/dist/protocol.d.ts +99 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +3 -0
- package/dist/protocol.js.map +7 -0
- package/dist/verification.d.ts +162 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +5 -0
- package/dist/verification.js.map +7 -0
- package/package.json +25 -3
- package/src/decision.ts +159 -0
- package/src/index.ts +5 -0
- package/src/plan.ts +177 -0
- package/src/protocol.ts +37 -0
- package/src/verification.ts +204 -0
- package/README.md +0 -3
package/src/plan.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { decisionRequestSchema, actionSchema } from './decision.js';
|
|
4
|
+
import {
|
|
5
|
+
completionChecksSchema,
|
|
6
|
+
controlLocatorSchema,
|
|
7
|
+
} from './verification.js';
|
|
8
|
+
|
|
9
|
+
/** Shared constraints for plan and step identifiers. */
|
|
10
|
+
const identifier = z.string().trim().min(1).max(200);
|
|
11
|
+
|
|
12
|
+
/** Fields added to each action when it becomes a planned step. */
|
|
13
|
+
const metadata = {
|
|
14
|
+
id: identifier,
|
|
15
|
+
message: z.string().trim().min(1).max(2000),
|
|
16
|
+
target: controlLocatorSchema,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Resolve these locators against a fresh observation before executing each step. */
|
|
20
|
+
export const plannedStepSchema = z.discriminatedUnion('type', [
|
|
21
|
+
actionSchema.options[0].extend(metadata),
|
|
22
|
+
actionSchema.options[1].extend(metadata),
|
|
23
|
+
actionSchema.options[2].extend(metadata),
|
|
24
|
+
actionSchema.options[3].extend(metadata),
|
|
25
|
+
actionSchema.options[4].extend(metadata),
|
|
26
|
+
actionSchema.options[5].extend(metadata),
|
|
27
|
+
actionSchema.options[6].extend(metadata),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** An ordered plan and the checks used to assess its outcome. */
|
|
31
|
+
export const planSchema = z.strictObject({
|
|
32
|
+
id: identifier,
|
|
33
|
+
replacesPlanId: identifier.optional(),
|
|
34
|
+
message: z.string().trim().min(1).max(2000),
|
|
35
|
+
steps: z
|
|
36
|
+
.array(plannedStepSchema)
|
|
37
|
+
.max(50)
|
|
38
|
+
.refine(
|
|
39
|
+
(steps) => new Set(steps.map((step) => step.id)).size === steps.length,
|
|
40
|
+
'Plan step IDs must be distinct.',
|
|
41
|
+
),
|
|
42
|
+
checks: completionChecksSchema,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Approval covers this concrete plan revision, including every action and argument. */
|
|
46
|
+
export const planApprovalSchema = z.strictObject({
|
|
47
|
+
type: z.literal('approval'),
|
|
48
|
+
message: z.string().trim().min(1).max(2000),
|
|
49
|
+
plan: planSchema,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A step that has already been executed as part of a plan. */
|
|
53
|
+
export const completedPlanStepSchema = z.strictObject({
|
|
54
|
+
planId: identifier,
|
|
55
|
+
step: plannedStepSchema,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Execution history and reason supplied when requesting a replacement plan. */
|
|
59
|
+
export const replanSchema = z.strictObject({
|
|
60
|
+
planId: identifier,
|
|
61
|
+
completedSteps: z.array(completedPlanStepSchema).max(200),
|
|
62
|
+
failedStepId: identifier.nullable(),
|
|
63
|
+
reason: z.enum(['target-unavailable', 'target-stale', 'outcome-mismatch']),
|
|
64
|
+
message: z.string().min(1).max(2000),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** JSON-compatible context accepted by the planning protocol. */
|
|
68
|
+
export type JsonValue =
|
|
69
|
+
string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
70
|
+
|
|
71
|
+
/** Return the estimated size of a JSON primitive, or null for other values. */
|
|
72
|
+
function primitiveSize(value: unknown): number | null {
|
|
73
|
+
if (value === null || typeof value === 'boolean') return 5;
|
|
74
|
+
if (typeof value === 'string') return value.length;
|
|
75
|
+
if (typeof value === 'number' && Number.isFinite(value)) return 24;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Accept arrays and plain objects, but not class instances or other objects. */
|
|
80
|
+
function isJsonContainer(value: object): boolean {
|
|
81
|
+
if (Array.isArray(value)) return true;
|
|
82
|
+
|
|
83
|
+
const prototype = Object.getPrototypeOf(value);
|
|
84
|
+
return prototype === Object.prototype || prototype === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Check that context is plain JSON within the protocol's traversal and size limits. */
|
|
88
|
+
function boundedJson(value: unknown): value is JsonValue {
|
|
89
|
+
const maxNodes = 2048;
|
|
90
|
+
const maxDepth = 8;
|
|
91
|
+
const maxSize = 32768;
|
|
92
|
+
|
|
93
|
+
// Track pending values, visited objects, and the estimated serialized size.
|
|
94
|
+
const pending = [{ value, depth: 0 }];
|
|
95
|
+
const seen = new Set<object>();
|
|
96
|
+
let size = 0;
|
|
97
|
+
let nodes = 0;
|
|
98
|
+
|
|
99
|
+
while (pending.length > 0) {
|
|
100
|
+
const item = pending.pop()!;
|
|
101
|
+
const current = item.value;
|
|
102
|
+
|
|
103
|
+
nodes += 1;
|
|
104
|
+
if (nodes > maxNodes || item.depth > maxDepth) return false;
|
|
105
|
+
|
|
106
|
+
const estimatedSize = primitiveSize(current);
|
|
107
|
+
if (estimatedSize !== null) {
|
|
108
|
+
size += estimatedSize;
|
|
109
|
+
} else if (typeof current === 'object' && current !== null) {
|
|
110
|
+
// Repeated references, cycles, and non-plain objects are not accepted.
|
|
111
|
+
if (seen.has(current)) return false;
|
|
112
|
+
seen.add(current);
|
|
113
|
+
if (!isJsonContainer(current)) return false;
|
|
114
|
+
|
|
115
|
+
// Account for property names and visit their values at the next depth.
|
|
116
|
+
for (const [key, child] of Object.entries(current)) {
|
|
117
|
+
size += key.length + 4;
|
|
118
|
+
pending.push({ value: child, depth: item.depth + 1 });
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (size > maxSize) return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Context is JSON data, bounded independently of page observations and images. */
|
|
131
|
+
export const planContextSchema = z.custom<JsonValue>(
|
|
132
|
+
boundedJson,
|
|
133
|
+
'Expected bounded JSON context.',
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/** Request for a plan, optionally including application context and replan history. */
|
|
137
|
+
export const planRequestSchema = decisionRequestSchema
|
|
138
|
+
.omit({ history: true })
|
|
139
|
+
.extend({
|
|
140
|
+
protocol: z.literal('plan'),
|
|
141
|
+
context: planContextSchema.optional(),
|
|
142
|
+
checks: completionChecksSchema.nullable(),
|
|
143
|
+
replan: replanSchema.nullable(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** A plan or a response explaining what the planner needs next. */
|
|
147
|
+
export const planResponseSchema = z.union([
|
|
148
|
+
planSchema,
|
|
149
|
+
planApprovalSchema,
|
|
150
|
+
z.strictObject({
|
|
151
|
+
type: z.literal('clarify'),
|
|
152
|
+
message: z.string().min(1).max(500),
|
|
153
|
+
}),
|
|
154
|
+
z.strictObject({
|
|
155
|
+
type: z.literal('screenshot'),
|
|
156
|
+
message: z.string().min(1).max(2000),
|
|
157
|
+
}),
|
|
158
|
+
z.strictObject({
|
|
159
|
+
type: z.literal('unsupported'),
|
|
160
|
+
message: z.string().min(1).max(2000),
|
|
161
|
+
}),
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/** A validated plan. */
|
|
165
|
+
export type Plan = z.infer<typeof planSchema>;
|
|
166
|
+
/** One action in a plan. */
|
|
167
|
+
export type PlannedStep = z.infer<typeof plannedStepSchema>;
|
|
168
|
+
/** A completed step and its originating plan. */
|
|
169
|
+
export type CompletedPlanStep = z.infer<typeof completedPlanStepSchema>;
|
|
170
|
+
/** The context for replacing a plan. */
|
|
171
|
+
export type Replan = z.infer<typeof replanSchema>;
|
|
172
|
+
/** A validated planning request. */
|
|
173
|
+
export type PlanRequest = z.infer<typeof planRequestSchema>;
|
|
174
|
+
/** A validated planning response. */
|
|
175
|
+
export type PlanResponse = z.infer<typeof planResponseSchema>;
|
|
176
|
+
/** A request to approve a concrete plan. */
|
|
177
|
+
export type PlanApproval = z.infer<typeof planApprovalSchema>;
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { DecisionRequest } from './decision.js';
|
|
4
|
+
import { completionChecksSchema, screenshotSchema } from './verification.js';
|
|
5
|
+
|
|
6
|
+
/** Name of the HTML metadata element used for runtime configuration. */
|
|
7
|
+
export const RUNTIME_META_NAME = 'ruakit-config';
|
|
8
|
+
|
|
9
|
+
/** A conversational response contains no executable instructions. */
|
|
10
|
+
export const clarificationSchema = z.strictObject({
|
|
11
|
+
type: z.literal('clarification'),
|
|
12
|
+
question: z.string().trim().min(1).max(500),
|
|
13
|
+
options: z
|
|
14
|
+
.array(z.string().trim().min(1).max(400))
|
|
15
|
+
.max(4)
|
|
16
|
+
.refine((items) => new Set(items).size === items.length, 'Expected distinct options.'),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Result of an execution, including any observed verification evidence. */
|
|
20
|
+
export const resultSchema = z.strictObject({
|
|
21
|
+
planId: z.string().min(1).max(200),
|
|
22
|
+
status: z.enum(['triggered', 'failed']),
|
|
23
|
+
events: z.array(z.string().max(500)).max(220),
|
|
24
|
+
goalCompleted: z.literal(true).optional(),
|
|
25
|
+
verification: z
|
|
26
|
+
.strictObject({ level: z.enum(['observed', 'confirmed']), checks: completionChecksSchema })
|
|
27
|
+
.optional(),
|
|
28
|
+
screenshot: screenshotSchema.optional(),
|
|
29
|
+
message: z.string().max(2000).optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** A question and optional choices returned to the user. */
|
|
33
|
+
export type Clarification = z.infer<typeof clarificationSchema>;
|
|
34
|
+
/** One question and answer included in a decision request. */
|
|
35
|
+
export type ClarificationTurn = DecisionRequest['clarifications'][number];
|
|
36
|
+
/** A validated execution result. */
|
|
37
|
+
export type ExecutionResult = z.infer<typeof resultSchema>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/** Identifies a control by its observed identity and accessible context. */
|
|
5
|
+
export const controlLocatorSchema = z.strictObject({
|
|
6
|
+
id: z.string().min(1).max(200).nullable(),
|
|
7
|
+
role: z.string().max(100),
|
|
8
|
+
label: z.string().max(1000),
|
|
9
|
+
context: z.string().max(1000).nullable(),
|
|
10
|
+
group: z.string().max(200).nullable(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Reuse the same locator constraints for all control checks. */
|
|
14
|
+
const locator = controlLocatorSchema;
|
|
15
|
+
|
|
16
|
+
/** Human-readable explanation of the outcome being checked. */
|
|
17
|
+
const description = z.string().trim().min(1).max(500);
|
|
18
|
+
|
|
19
|
+
/** A single observable condition that can establish progress toward a goal. */
|
|
20
|
+
export const completionCheckSchema = z.discriminatedUnion('type', [
|
|
21
|
+
z.strictObject({
|
|
22
|
+
type: z.literal('value'),
|
|
23
|
+
description,
|
|
24
|
+
control: locator,
|
|
25
|
+
equals: z.string().max(20000),
|
|
26
|
+
}),
|
|
27
|
+
z.strictObject({
|
|
28
|
+
type: z.literal('checked'),
|
|
29
|
+
description,
|
|
30
|
+
control: locator,
|
|
31
|
+
equals: z.boolean(),
|
|
32
|
+
}),
|
|
33
|
+
z.strictObject({
|
|
34
|
+
type: z.literal('selected'),
|
|
35
|
+
description,
|
|
36
|
+
control: locator,
|
|
37
|
+
equals: z.array(z.string().max(4000)).max(100),
|
|
38
|
+
}),
|
|
39
|
+
z.strictObject({
|
|
40
|
+
type: z.literal('text'),
|
|
41
|
+
description,
|
|
42
|
+
text: z.string().trim().min(1).max(2000),
|
|
43
|
+
fresh: z.boolean(),
|
|
44
|
+
}),
|
|
45
|
+
z.strictObject({
|
|
46
|
+
type: z.literal('url'),
|
|
47
|
+
description,
|
|
48
|
+
equals: z.string().min(1).max(4000),
|
|
49
|
+
}),
|
|
50
|
+
z.strictObject({ type: z.literal('absent'), description, control: locator }),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** A nonempty, bounded collection of outcome checks. */
|
|
54
|
+
export const completionChecksSchema = z.array(completionCheckSchema).min(1).max(20);
|
|
55
|
+
|
|
56
|
+
/** A validated observable outcome check. */
|
|
57
|
+
export type CompletionCheck = z.infer<typeof completionCheckSchema>;
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/** The observed page information needed to evaluate outcome checks. */
|
|
61
|
+
export interface VerificationState {
|
|
62
|
+
url: string;
|
|
63
|
+
text: string;
|
|
64
|
+
truncated: boolean;
|
|
65
|
+
controls: {
|
|
66
|
+
target: { id: string; version: number };
|
|
67
|
+
role: string;
|
|
68
|
+
label: string;
|
|
69
|
+
context: string;
|
|
70
|
+
groups?: { name: string }[] | undefined;
|
|
71
|
+
value?: string | undefined;
|
|
72
|
+
checked?: boolean | undefined;
|
|
73
|
+
selectedValues?: string[] | undefined;
|
|
74
|
+
}[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type ObservedControl = VerificationState['controls'][number];
|
|
78
|
+
type ControlLocator = z.infer<typeof controlLocatorSchema>;
|
|
79
|
+
type ControlCheck = Extract<CompletionCheck, { control: ControlLocator }>;
|
|
80
|
+
|
|
81
|
+
/** Match accessible characteristics independently of the observation's target ID. */
|
|
82
|
+
function matchesControl(control: ObservedControl, locator: ControlLocator): boolean {
|
|
83
|
+
return (
|
|
84
|
+
control.role === locator.role &&
|
|
85
|
+
control.label === locator.label &&
|
|
86
|
+
(locator.context === null || control.context === locator.context) &&
|
|
87
|
+
(locator.group === null ||
|
|
88
|
+
control.groups?.some((group) => group.name === locator.group) === true)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Check visible text without treating a control's caption as an acknowledgement. */
|
|
93
|
+
function matchesText(
|
|
94
|
+
check: Extract<CompletionCheck, { type: 'text' }>,
|
|
95
|
+
state: VerificationState,
|
|
96
|
+
baseline: VerificationState,
|
|
97
|
+
): boolean {
|
|
98
|
+
const isVisible = state.text.includes(check.text);
|
|
99
|
+
const isControlLabel = state.controls.some((control) => control.label.includes(check.text));
|
|
100
|
+
const isFresh = !check.fresh || !baseline.text.includes(check.text);
|
|
101
|
+
|
|
102
|
+
return isVisible && !isControlLabel && isFresh;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Compare the observed control's state with the expected value. */
|
|
106
|
+
function matchesControlValue(
|
|
107
|
+
check: Exclude<ControlCheck, { type: 'absent' }>,
|
|
108
|
+
control: ObservedControl,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (check.type === 'value') return control.value === check.equals;
|
|
111
|
+
if (check.type === 'checked') return control.checked === check.equals;
|
|
112
|
+
|
|
113
|
+
const selectedValues = control.selectedValues;
|
|
114
|
+
if (selectedValues === undefined) return false;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
selectedValues.length === check.equals.length &&
|
|
118
|
+
new Set(check.equals).size === check.equals.length &&
|
|
119
|
+
check.equals.every((value) => selectedValues.includes(value))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolve a control check against the current observation. */
|
|
124
|
+
function matchesControlCheck(check: ControlCheck, state: VerificationState): boolean {
|
|
125
|
+
const semanticMatches = state.controls.filter((control) => matchesControl(control, check.control));
|
|
126
|
+
|
|
127
|
+
// A recycled target ID must not hide a control when checking for absence.
|
|
128
|
+
if (check.type === 'absent') return !state.truncated && semanticMatches.length === 0;
|
|
129
|
+
|
|
130
|
+
const matches = semanticMatches.filter(
|
|
131
|
+
(control) => check.control.id === null || control.target.id === check.control.id,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// A value can only be verified when the locator identifies exactly one control.
|
|
135
|
+
if (matches.length !== 1) return false;
|
|
136
|
+
return matchesControlValue(check, matches[0]!);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Deterministic checks of observable outcomes. This does not infer intent or prove backend persistence. */
|
|
140
|
+
export function evaluateCompletionChecks(
|
|
141
|
+
checks: readonly CompletionCheck[],
|
|
142
|
+
state: VerificationState,
|
|
143
|
+
baseline: VerificationState = state,
|
|
144
|
+
): { passed: boolean; checks: { description: string; passed: boolean }[] } {
|
|
145
|
+
const results = checks.map((check) => {
|
|
146
|
+
let passed: boolean;
|
|
147
|
+
|
|
148
|
+
if (check.type === 'text') {
|
|
149
|
+
passed = matchesText(check, state, baseline);
|
|
150
|
+
} else if (check.type === 'url') {
|
|
151
|
+
passed = state.url === check.equals;
|
|
152
|
+
} else {
|
|
153
|
+
passed = matchesControlCheck(check, state);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { description: check.description, passed };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
passed: results.length > 0 && results.every((result) => result.passed),
|
|
161
|
+
checks: results,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A bounded, encoded screenshot with its capture metadata. */
|
|
166
|
+
export const screenshotSchema = z
|
|
167
|
+
.strictObject({
|
|
168
|
+
data: z
|
|
169
|
+
.string()
|
|
170
|
+
.min(1)
|
|
171
|
+
.max(7_000_000)
|
|
172
|
+
.regex(/^[A-Za-z0-9+/]+={0,2}$/)
|
|
173
|
+
.refine((data) => data.length % 4 === 0),
|
|
174
|
+
mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
175
|
+
width: z.number().int().positive().max(8192),
|
|
176
|
+
height: z.number().int().positive().max(8192),
|
|
177
|
+
capturedAt: z.number().int().nonnegative(),
|
|
178
|
+
url: z.string().max(4000),
|
|
179
|
+
})
|
|
180
|
+
.superRefine((image, context) => {
|
|
181
|
+
try {
|
|
182
|
+
// Decode the payload before checking its size and file signature.
|
|
183
|
+
const bytes = globalThis.atob(image.data);
|
|
184
|
+
|
|
185
|
+
const signatureMatches =
|
|
186
|
+
image.mimeType === 'image/png'
|
|
187
|
+
? bytes.startsWith('\x89PNG\r\n\x1a\n')
|
|
188
|
+
: image.mimeType === 'image/jpeg'
|
|
189
|
+
? bytes.startsWith('\xff\xd8\xff')
|
|
190
|
+
: bytes.startsWith('RIFF') && bytes.slice(8, 12) === 'WEBP';
|
|
191
|
+
|
|
192
|
+
if (bytes.length > 5 * 1024 * 1024 || !signatureMatches) {
|
|
193
|
+
context.addIssue({
|
|
194
|
+
code: 'custom',
|
|
195
|
+
message: 'Screenshot bytes must match the image type and fit within 5 MiB.',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
context.addIssue({
|
|
200
|
+
code: 'custom',
|
|
201
|
+
message: 'Screenshot data must contain valid base64 image bytes.',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
package/README.md
DELETED