@ggui-ai/negotiator 0.25.0 → 0.26.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 +8 -6
- package/dist/ensure-conforming-contract.d.ts +8 -6
- package/dist/ensure-conforming-contract.d.ts.map +1 -1
- package/dist/ensure-conforming-contract.js +7 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/preserve-declared-members.d.ts +89 -0
- package/dist/preserve-declared-members.d.ts.map +1 -0
- package/dist/preserve-declared-members.js +427 -0
- package/dist/synthesize-contract.d.ts.map +1 -1
- package/dist/synthesize-contract.js +20 -16
- package/package.json +3 -3
- package/src/ensure-conforming-contract.ts +15 -8
- package/src/index.ts +5 -3
- package/src/preserve-declared-members.ts +521 -0
- package/src/synthesize-contract.ts +22 -16
- package/dist/contract-hash.d.ts +0 -54
- package/dist/contract-hash.d.ts.map +0 -1
- package/dist/contract-hash.js +0 -96
- package/dist/preserve-action-members.d.ts +0 -79
- package/dist/preserve-action-members.d.ts.map +0 -1
- package/dist/preserve-action-members.js +0 -199
- package/src/contract-hash.ts +0 -102
- package/src/preserve-action-members.ts +0 -224
package/dist/contract-hash.js
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract Hashing — deterministic cache key from data contract.
|
|
3
|
-
*
|
|
4
|
-
* Uses RFC 8785 (JSON Canonicalization Scheme) for deterministic
|
|
5
|
-
* serialization, then SHA-256 for the hash.
|
|
6
|
-
*
|
|
7
|
-
* What's in the hash: intent only (the semantic identity).
|
|
8
|
-
* What's NOT in the hash: props, actions, stream (including per-channel source), adaptations.
|
|
9
|
-
*
|
|
10
|
-
* Why intent-only: The LLM generates slightly different prop descriptions
|
|
11
|
-
* each call, but the INTENT is deterministic (provided by the agent or
|
|
12
|
-
* generated once). Same intent = same component = same cached code.
|
|
13
|
-
* Props are passed at render time, not baked into the component.
|
|
14
|
-
*
|
|
15
|
-
* The `interaction` field was removed from `DataContract` in pre-launch
|
|
16
|
-
* cleanup (the four-spec surface — props/action/context/stream —
|
|
17
|
-
* describes the wire exhaustively); this function used to factor it in
|
|
18
|
-
* but no longer does. Old goldens were rotated when the field went away.
|
|
19
|
-
*/
|
|
20
|
-
import { createHash } from 'node:crypto';
|
|
21
|
-
/**
|
|
22
|
-
* RFC 8785 JSON Canonicalization Scheme.
|
|
23
|
-
*
|
|
24
|
-
* Produces deterministic JSON by:
|
|
25
|
-
* - Sorting object keys lexicographically (recursive)
|
|
26
|
-
* - Normalizing numbers (no trailing zeros, no +0)
|
|
27
|
-
* - Removing undefined values
|
|
28
|
-
* - No whitespace
|
|
29
|
-
*/
|
|
30
|
-
function canonicalize(value) {
|
|
31
|
-
if (value === null || value === undefined)
|
|
32
|
-
return 'null';
|
|
33
|
-
if (typeof value === 'boolean')
|
|
34
|
-
return value ? 'true' : 'false';
|
|
35
|
-
if (typeof value === 'number') {
|
|
36
|
-
if (!isFinite(value))
|
|
37
|
-
return 'null';
|
|
38
|
-
return Object.is(value, -0) ? '0' : String(value);
|
|
39
|
-
}
|
|
40
|
-
if (typeof value === 'string')
|
|
41
|
-
return JSON.stringify(value);
|
|
42
|
-
if (Array.isArray(value)) {
|
|
43
|
-
return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
|
|
44
|
-
}
|
|
45
|
-
if (typeof value === 'object') {
|
|
46
|
-
const obj = value;
|
|
47
|
-
const keys = Object.keys(obj)
|
|
48
|
-
.filter((k) => obj[k] !== undefined)
|
|
49
|
-
.sort();
|
|
50
|
-
const entries = keys.map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k]));
|
|
51
|
-
return '{' + entries.join(',') + '}';
|
|
52
|
-
}
|
|
53
|
-
return 'null';
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Hash a data contract to a deterministic cache key.
|
|
57
|
-
*
|
|
58
|
-
* Hashes intent only — the semantic identity of the UI. Props,
|
|
59
|
-
* actions, stream specs are for code generation, not caching.
|
|
60
|
-
*
|
|
61
|
-
* `intent` is passed separately because `DataContract` itself does
|
|
62
|
-
* not carry an `intent` field — the outer pipeline owns intent
|
|
63
|
-
* (`story.intent` on `ggui_render`, the operator prompt for harness
|
|
64
|
-
* benchmarks). Threading intent into the hash keeps cache identity
|
|
65
|
-
* stable across negotiations: different intents produce different
|
|
66
|
-
* generated code (labels, copy, layout) even when the wire surface is
|
|
67
|
-
* identical.
|
|
68
|
-
*
|
|
69
|
-
* The `contract` argument is retained on the signature for forward
|
|
70
|
-
* compatibility — future cache-key fields (e.g., a structural
|
|
71
|
-
* fingerprint of the four specs) can be folded in without breaking
|
|
72
|
-
* call sites — but is not consulted by the current implementation.
|
|
73
|
-
*
|
|
74
|
-
* @param contract - The data contract from negotiation (unused today)
|
|
75
|
-
* @param intent - The outer pipeline's intent (story.intent on
|
|
76
|
-
* ggui_render). Empty/falsy values are dropped from the hash input.
|
|
77
|
-
* @returns Contract hash prefixed with `ch_` (e.g., `ch_a3f8b2c1e9d04567`)
|
|
78
|
-
*/
|
|
79
|
-
export function hashContract(_contract, intent) {
|
|
80
|
-
const input = {};
|
|
81
|
-
if (intent)
|
|
82
|
-
input.intent = intent;
|
|
83
|
-
const canonical = canonicalize(input);
|
|
84
|
-
const hash = createHash('sha256').update(canonical).digest('hex');
|
|
85
|
-
return `ch_${hash.slice(0, 16)}`;
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Build a variant key from shell type and device category.
|
|
89
|
-
*
|
|
90
|
-
* @param shellType - Shell type (default: 'universal')
|
|
91
|
-
* @param deviceCategory - Device category (default: 'universal')
|
|
92
|
-
* @returns Variant string (e.g., 'fullscreen:mobile', 'universal:universal')
|
|
93
|
-
*/
|
|
94
|
-
export function buildVariant(shellType = 'universal', deviceCategory = 'universal') {
|
|
95
|
-
return `${shellType}:${deviceCategory}`;
|
|
96
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Action-entry member preservation — the deterministic backstop that
|
|
3
|
-
* keeps a repair FAITHFUL to what the agent DECLARED on its actions
|
|
4
|
-
* (ggui#1421).
|
|
5
|
-
*
|
|
6
|
-
* The repair tool authors exactly two members per action, `label` and
|
|
7
|
-
* `schema` (`SYNTHESIZE_TOOL`); every other member of an
|
|
8
|
-
* {@link ActionEntry} — `description`, `example`, `icon`, `confirm`,
|
|
9
|
-
* `oneShot`, `nextStep` — is the agent's declaration, and the whole
|
|
10
|
-
* one-shot line (ggui#1108 → ggui#1223: the runtime's second-dispatch
|
|
11
|
-
* guard, the server's spend record, ui-gen's `useActionSpent` binding)
|
|
12
|
-
* keys on `oneShot === true` being ON THE SERVED CONTRACT. A repair that
|
|
13
|
-
* re-emits an entry from the tool's answer alone serves a card with no
|
|
14
|
-
* guard, silently. So the overlay below puts the draft's declarations
|
|
15
|
-
* back on the repaired candidate, deterministically, and NAMES whatever
|
|
16
|
-
* it cannot keep:
|
|
17
|
-
*
|
|
18
|
-
* - `REPAIR_MEMBER_DROPPED` at `actionSpec.<name>.<member>` — a
|
|
19
|
-
* declared member the merged contract cannot carry (the gate refuses
|
|
20
|
-
* it there, e.g. a `nextStep` whose tool the repair did not
|
|
21
|
-
* re-declare, or a member that fails its own schema);
|
|
22
|
-
* - `REPAIR_ENTRY_DROPPED` at `actionSpec.<name>` — a draft action the
|
|
23
|
-
* candidate no longer carries under that name (renamed or removed),
|
|
24
|
-
* which takes every member with it.
|
|
25
|
-
*
|
|
26
|
-
* A member whose path a DRAFT finding already names is under repair: it
|
|
27
|
-
* is not restored, and its absence is named by that finding, at the same
|
|
28
|
-
* path. Every other loss is named HERE, at the member's or the entry's
|
|
29
|
-
* own path, with the gate's reason in the message — so the invariant an
|
|
30
|
-
* agent can rely on is one sentence: a declared member or action missing
|
|
31
|
-
* from the served proposal always has a finding at its own path. The
|
|
32
|
-
* `CTR_*` codes say what the gate refused; the `REPAIR_*` codes say what
|
|
33
|
-
* the served proposal does not carry of the declaration, and why. The
|
|
34
|
-
* codes are handshake-decision codes like `COVERAGE_GAP` — declared where
|
|
35
|
-
* they are emitted, read by the agent as strings in `validationFindings`.
|
|
36
|
-
* Model-independent: the overlay works whatever the repair LLM returns.
|
|
37
|
-
*/
|
|
38
|
-
import { type DataContract, type SuggestionFinding } from '@ggui-ai/protocol';
|
|
39
|
-
/** A declared action-entry member the repair could not keep; path `actionSpec.<name>.<member>`. */
|
|
40
|
-
export declare const REPAIR_MEMBER_DROPPED: "REPAIR_MEMBER_DROPPED";
|
|
41
|
-
/** A draft action entry the repair no longer carries under its name; path `actionSpec.<name>`. */
|
|
42
|
-
export declare const REPAIR_ENTRY_DROPPED: "REPAIR_ENTRY_DROPPED";
|
|
43
|
-
/**
|
|
44
|
-
* Whether `finding` is a member drop on one of `entryNames` — matched by
|
|
45
|
-
* the exact `actionSpec.<name>.<member>` path per declared member, never
|
|
46
|
-
* by a dotted prefix (an action named `a` is not a prefix of `a.b`).
|
|
47
|
-
*/
|
|
48
|
-
export declare function isMemberDropOf(finding: SuggestionFinding, entryNames: readonly string[]): boolean;
|
|
49
|
-
/**
|
|
50
|
-
* Overlay the draft's declared members onto the candidate's action
|
|
51
|
-
* entries (same name), keeping the candidate's `label` and `schema`. A
|
|
52
|
-
* member whose exact path a draft finding names is under repair and
|
|
53
|
-
* stays out. Then lint the merged contract and un-restore, until the
|
|
54
|
-
* tree is stable, every restored member the gate refuses — at its own
|
|
55
|
-
* path, or (for `nextStep`) as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
56
|
-
* `.schema`, the path the compatibility check reports on — naming each
|
|
57
|
-
* one, so the returned contract never fails the gate BECAUSE of the
|
|
58
|
-
* overlay. A malformed candidate entry (no `label`) is left un-overlaid
|
|
59
|
-
* for the loop's own gate to retry; nothing here throws on the model's
|
|
60
|
-
* answer. Entries the candidate does not carry get nothing back here —
|
|
61
|
-
* see {@link findDroppedActionEntries}.
|
|
62
|
-
*/
|
|
63
|
-
export declare function restoreDraftActionMembers(draft: unknown, candidate: DataContract, underRepair: ReadonlySet<string>): {
|
|
64
|
-
readonly contract: DataContract;
|
|
65
|
-
readonly dropped: readonly SuggestionFinding[];
|
|
66
|
-
};
|
|
67
|
-
/**
|
|
68
|
-
* One `REPAIR_ENTRY_DROPPED` per draft action entry the candidate no
|
|
69
|
-
* longer carries under that name — renamed or removed by the repair (or
|
|
70
|
-
* pruned by the loop's own placement pass) — always, because the members
|
|
71
|
-
* it carried (`oneShot` above all) went with it and nothing else names
|
|
72
|
-
* that. When the gate refused the NAME itself (`CTR_DUP_NAME` /
|
|
73
|
-
* `CTR_RESERVED_NAME` at `actionSpec.<name>`, so the path is under
|
|
74
|
-
* repair) the message says so and does not send the agent back to a
|
|
75
|
-
* name the gate will refuse again. Empty when every draft entry survived,
|
|
76
|
-
* and for drafts that declare no actions.
|
|
77
|
-
*/
|
|
78
|
-
export declare function findDroppedActionEntries(draft: unknown, candidate: DataContract, underRepair: ReadonlySet<string>): readonly SuggestionFinding[];
|
|
79
|
-
//# sourceMappingURL=preserve-action-members.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"preserve-action-members.d.ts","sourceRoot":"","sources":["../src/preserve-action-members.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAE3B,mGAAmG;AACnG,eAAO,MAAM,qBAAqB,EAAG,uBAAgC,CAAC;AACtE,kGAAkG;AAClG,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AA6CpE;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAEjG;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B;IAAE,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAAE,CAuErF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B,SAAS,iBAAiB,EAAE,CAc9B"}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Action-entry member preservation — the deterministic backstop that
|
|
3
|
-
* keeps a repair FAITHFUL to what the agent DECLARED on its actions
|
|
4
|
-
* (ggui#1421).
|
|
5
|
-
*
|
|
6
|
-
* The repair tool authors exactly two members per action, `label` and
|
|
7
|
-
* `schema` (`SYNTHESIZE_TOOL`); every other member of an
|
|
8
|
-
* {@link ActionEntry} — `description`, `example`, `icon`, `confirm`,
|
|
9
|
-
* `oneShot`, `nextStep` — is the agent's declaration, and the whole
|
|
10
|
-
* one-shot line (ggui#1108 → ggui#1223: the runtime's second-dispatch
|
|
11
|
-
* guard, the server's spend record, ui-gen's `useActionSpent` binding)
|
|
12
|
-
* keys on `oneShot === true` being ON THE SERVED CONTRACT. A repair that
|
|
13
|
-
* re-emits an entry from the tool's answer alone serves a card with no
|
|
14
|
-
* guard, silently. So the overlay below puts the draft's declarations
|
|
15
|
-
* back on the repaired candidate, deterministically, and NAMES whatever
|
|
16
|
-
* it cannot keep:
|
|
17
|
-
*
|
|
18
|
-
* - `REPAIR_MEMBER_DROPPED` at `actionSpec.<name>.<member>` — a
|
|
19
|
-
* declared member the merged contract cannot carry (the gate refuses
|
|
20
|
-
* it there, e.g. a `nextStep` whose tool the repair did not
|
|
21
|
-
* re-declare, or a member that fails its own schema);
|
|
22
|
-
* - `REPAIR_ENTRY_DROPPED` at `actionSpec.<name>` — a draft action the
|
|
23
|
-
* candidate no longer carries under that name (renamed or removed),
|
|
24
|
-
* which takes every member with it.
|
|
25
|
-
*
|
|
26
|
-
* A member whose path a DRAFT finding already names is under repair: it
|
|
27
|
-
* is not restored, and its absence is named by that finding, at the same
|
|
28
|
-
* path. Every other loss is named HERE, at the member's or the entry's
|
|
29
|
-
* own path, with the gate's reason in the message — so the invariant an
|
|
30
|
-
* agent can rely on is one sentence: a declared member or action missing
|
|
31
|
-
* from the served proposal always has a finding at its own path. The
|
|
32
|
-
* `CTR_*` codes say what the gate refused; the `REPAIR_*` codes say what
|
|
33
|
-
* the served proposal does not carry of the declaration, and why. The
|
|
34
|
-
* codes are handshake-decision codes like `COVERAGE_GAP` — declared where
|
|
35
|
-
* they are emitted, read by the agent as strings in `validationFindings`.
|
|
36
|
-
* Model-independent: the overlay works whatever the repair LLM returns.
|
|
37
|
-
*/
|
|
38
|
-
import { actionEntrySchema, CTR_SCHEMA_INCOMPAT, lintContract, } from '@ggui-ai/protocol';
|
|
39
|
-
/** A declared action-entry member the repair could not keep; path `actionSpec.<name>.<member>`. */
|
|
40
|
-
export const REPAIR_MEMBER_DROPPED = 'REPAIR_MEMBER_DROPPED';
|
|
41
|
-
/** A draft action entry the repair no longer carries under its name; path `actionSpec.<name>`. */
|
|
42
|
-
export const REPAIR_ENTRY_DROPPED = 'REPAIR_ENTRY_DROPPED';
|
|
43
|
-
/**
|
|
44
|
-
* The members the agent declares — the protocol's action-entry schema
|
|
45
|
-
* minus the pair the repair tool authors. Derived, never listed, so a
|
|
46
|
-
* new schema member is preserved the day it lands.
|
|
47
|
-
*/
|
|
48
|
-
const DECLARED_MEMBERS = Object.keys(actionEntrySchema.shape).filter((k) => k !== 'label' && k !== 'schema');
|
|
49
|
-
function isRecord(value) {
|
|
50
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
-
}
|
|
52
|
-
/** The draft's `actionSpec` map when it is one; `undefined` otherwise (the draft is untrusted). */
|
|
53
|
-
function draftActionSpec(draft) {
|
|
54
|
-
if (!isRecord(draft))
|
|
55
|
-
return undefined;
|
|
56
|
-
const actionSpec = draft['actionSpec'];
|
|
57
|
-
return isRecord(actionSpec) ? actionSpec : undefined;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* `entry` without `member`. Removing an optional member from a parsed
|
|
61
|
-
* entry leaves a parsed entry, so the strict re-parse re-derives the
|
|
62
|
-
* typed shape without a cast.
|
|
63
|
-
*/
|
|
64
|
-
function withoutMember(entry, member) {
|
|
65
|
-
return actionEntrySchema.parse(Object.fromEntries(Object.entries(entry).filter(([key]) => key !== member)));
|
|
66
|
-
}
|
|
67
|
-
function memberDropped(name, member, reason) {
|
|
68
|
-
return {
|
|
69
|
-
code: REPAIR_MEMBER_DROPPED,
|
|
70
|
-
severity: 'error',
|
|
71
|
-
path: `actionSpec.${name}.${member}`,
|
|
72
|
-
message: `the repair could not keep the declared \`${member}\` on action '${name}': ${reason}`,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Whether `finding` is a member drop on one of `entryNames` — matched by
|
|
77
|
-
* the exact `actionSpec.<name>.<member>` path per declared member, never
|
|
78
|
-
* by a dotted prefix (an action named `a` is not a prefix of `a.b`).
|
|
79
|
-
*/
|
|
80
|
-
export function isMemberDropOf(finding, entryNames) {
|
|
81
|
-
return entryNames.some((name) => DECLARED_MEMBERS.some((member) => finding.path === `actionSpec.${name}.${member}`));
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Overlay the draft's declared members onto the candidate's action
|
|
85
|
-
* entries (same name), keeping the candidate's `label` and `schema`. A
|
|
86
|
-
* member whose exact path a draft finding names is under repair and
|
|
87
|
-
* stays out. Then lint the merged contract and un-restore, until the
|
|
88
|
-
* tree is stable, every restored member the gate refuses — at its own
|
|
89
|
-
* path, or (for `nextStep`) as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
90
|
-
* `.schema`, the path the compatibility check reports on — naming each
|
|
91
|
-
* one, so the returned contract never fails the gate BECAUSE of the
|
|
92
|
-
* overlay. A malformed candidate entry (no `label`) is left un-overlaid
|
|
93
|
-
* for the loop's own gate to retry; nothing here throws on the model's
|
|
94
|
-
* answer. Entries the candidate does not carry get nothing back here —
|
|
95
|
-
* see {@link findDroppedActionEntries}.
|
|
96
|
-
*/
|
|
97
|
-
export function restoreDraftActionMembers(draft, candidate, underRepair) {
|
|
98
|
-
const draftActions = draftActionSpec(draft);
|
|
99
|
-
if (draftActions === undefined || candidate.actionSpec === undefined) {
|
|
100
|
-
return { contract: candidate, dropped: [] };
|
|
101
|
-
}
|
|
102
|
-
const dropped = [];
|
|
103
|
-
/** Per entry, the members the overlay put back. */
|
|
104
|
-
const restored = new Map();
|
|
105
|
-
const merged = {};
|
|
106
|
-
for (const [name, entry] of Object.entries(candidate.actionSpec)) {
|
|
107
|
-
const draftEntry = Object.hasOwn(draftActions, name) ? draftActions[name] : undefined;
|
|
108
|
-
if (!isRecord(draftEntry)) {
|
|
109
|
-
merged[name] = entry;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
const next = { ...entry };
|
|
113
|
-
const put = new Set();
|
|
114
|
-
for (const member of DECLARED_MEMBERS) {
|
|
115
|
-
const value = draftEntry[member];
|
|
116
|
-
if (value === undefined || entry[member] !== undefined || underRepair.has(`actionSpec.${name}.${member}`))
|
|
117
|
-
continue;
|
|
118
|
-
const parsed = actionEntrySchema.shape[member].safeParse(value);
|
|
119
|
-
if (!parsed.success) {
|
|
120
|
-
dropped.push(memberDropped(name, member, parsed.error.issues[0]?.message ?? 'invalid'));
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
next[member] = parsed.data;
|
|
124
|
-
put.add(member);
|
|
125
|
-
}
|
|
126
|
-
const reparsed = actionEntrySchema.safeParse(next);
|
|
127
|
-
if (put.size === 0 || !reparsed.success) {
|
|
128
|
-
// Nothing to put back, or the CANDIDATE's own label/schema are not
|
|
129
|
-
// an entry yet — the loop's gate retries that; not this overlay's.
|
|
130
|
-
merged[name] = entry;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
merged[name] = reparsed.data;
|
|
134
|
-
restored.set(name, put);
|
|
135
|
-
}
|
|
136
|
-
let actionSpec = merged;
|
|
137
|
-
if (restored.size === 0)
|
|
138
|
-
return { contract: { ...candidate, actionSpec }, dropped };
|
|
139
|
-
// Cross-reference and compatibility errors only exist on the MERGED
|
|
140
|
-
// tree. Un-restore exactly the members the gate names — at their own
|
|
141
|
-
// path, or a `nextStep` as `CTR_SCHEMA_INCOMPAT` at the sibling
|
|
142
|
-
// `.schema`, the path the compatibility check reports on — naming each
|
|
143
|
-
// at ITS OWN path with the gate's reason; re-lint, and stop when a pass
|
|
144
|
-
// changes nothing. Every pass removes at least one member, so the loop
|
|
145
|
-
// is bounded by what was restored.
|
|
146
|
-
for (let pass = 0; pass < DECLARED_MEMBERS.length * restored.size + 1; pass += 1) {
|
|
147
|
-
const errors = lintContract({ ...candidate, actionSpec }).errors;
|
|
148
|
-
let changed = false;
|
|
149
|
-
for (const [name, members] of restored) {
|
|
150
|
-
for (const member of [...members]) {
|
|
151
|
-
const refusal = errors.find((issue) => issue.path === `actionSpec.${name}.${member}`) ??
|
|
152
|
-
(member === 'nextStep'
|
|
153
|
-
? errors.find((issue) => issue.code === CTR_SCHEMA_INCOMPAT && issue.path === `actionSpec.${name}.schema`)
|
|
154
|
-
: undefined);
|
|
155
|
-
if (refusal === undefined)
|
|
156
|
-
continue;
|
|
157
|
-
const entry = actionSpec[name];
|
|
158
|
-
if (entry === undefined)
|
|
159
|
-
continue;
|
|
160
|
-
actionSpec = { ...actionSpec, [name]: withoutMember(entry, member) };
|
|
161
|
-
members.delete(member);
|
|
162
|
-
dropped.push(memberDropped(name, member, `refused by the gate as ${refusal.code} at ${refusal.path}: ${refusal.message}`));
|
|
163
|
-
changed = true;
|
|
164
|
-
}
|
|
165
|
-
if (members.size === 0)
|
|
166
|
-
restored.delete(name);
|
|
167
|
-
}
|
|
168
|
-
if (!changed || restored.size === 0)
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
return { contract: { ...candidate, actionSpec }, dropped };
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* One `REPAIR_ENTRY_DROPPED` per draft action entry the candidate no
|
|
175
|
-
* longer carries under that name — renamed or removed by the repair (or
|
|
176
|
-
* pruned by the loop's own placement pass) — always, because the members
|
|
177
|
-
* it carried (`oneShot` above all) went with it and nothing else names
|
|
178
|
-
* that. When the gate refused the NAME itself (`CTR_DUP_NAME` /
|
|
179
|
-
* `CTR_RESERVED_NAME` at `actionSpec.<name>`, so the path is under
|
|
180
|
-
* repair) the message says so and does not send the agent back to a
|
|
181
|
-
* name the gate will refuse again. Empty when every draft entry survived,
|
|
182
|
-
* and for drafts that declare no actions.
|
|
183
|
-
*/
|
|
184
|
-
export function findDroppedActionEntries(draft, candidate, underRepair) {
|
|
185
|
-
const draftActions = draftActionSpec(draft);
|
|
186
|
-
if (draftActions === undefined)
|
|
187
|
-
return [];
|
|
188
|
-
const kept = candidate.actionSpec ?? {};
|
|
189
|
-
return Object.keys(draftActions)
|
|
190
|
-
.filter((name) => !Object.hasOwn(kept, name))
|
|
191
|
-
.map((name) => ({
|
|
192
|
-
code: REPAIR_ENTRY_DROPPED,
|
|
193
|
-
severity: 'error',
|
|
194
|
-
path: `actionSpec.${name}`,
|
|
195
|
-
message: underRepair.has(`actionSpec.${name}`)
|
|
196
|
-
? `the gate refused the action name '${name}' (see the finding at this path) and the repair removed or renamed it; every member it carried, oneShot included, went with it — declare those members again under a name the gate accepts, via ggui_render override or a corrected re-handshake`
|
|
197
|
-
: `the repair no longer carries the action '${name}' you declared (renamed or removed); every member it carried, oneShot included, went with it — re-declare it via ggui_render override or a corrected re-handshake`,
|
|
198
|
-
}));
|
|
199
|
-
}
|
package/src/contract-hash.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract Hashing — deterministic cache key from data contract.
|
|
3
|
-
*
|
|
4
|
-
* Uses RFC 8785 (JSON Canonicalization Scheme) for deterministic
|
|
5
|
-
* serialization, then SHA-256 for the hash.
|
|
6
|
-
*
|
|
7
|
-
* What's in the hash: intent only (the semantic identity).
|
|
8
|
-
* What's NOT in the hash: props, actions, stream (including per-channel source), adaptations.
|
|
9
|
-
*
|
|
10
|
-
* Why intent-only: The LLM generates slightly different prop descriptions
|
|
11
|
-
* each call, but the INTENT is deterministic (provided by the agent or
|
|
12
|
-
* generated once). Same intent = same component = same cached code.
|
|
13
|
-
* Props are passed at render time, not baked into the component.
|
|
14
|
-
*
|
|
15
|
-
* The `interaction` field was removed from `DataContract` in pre-launch
|
|
16
|
-
* cleanup (the four-spec surface — props/action/context/stream —
|
|
17
|
-
* describes the wire exhaustively); this function used to factor it in
|
|
18
|
-
* but no longer does. Old goldens were rotated when the field went away.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { createHash } from 'node:crypto';
|
|
22
|
-
import type { DataContract } from '@ggui-ai/protocol';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* RFC 8785 JSON Canonicalization Scheme.
|
|
26
|
-
*
|
|
27
|
-
* Produces deterministic JSON by:
|
|
28
|
-
* - Sorting object keys lexicographically (recursive)
|
|
29
|
-
* - Normalizing numbers (no trailing zeros, no +0)
|
|
30
|
-
* - Removing undefined values
|
|
31
|
-
* - No whitespace
|
|
32
|
-
*/
|
|
33
|
-
function canonicalize(value: unknown): string {
|
|
34
|
-
if (value === null || value === undefined) return 'null';
|
|
35
|
-
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
36
|
-
if (typeof value === 'number') {
|
|
37
|
-
if (!isFinite(value)) return 'null';
|
|
38
|
-
return Object.is(value, -0) ? '0' : String(value);
|
|
39
|
-
}
|
|
40
|
-
if (typeof value === 'string') return JSON.stringify(value);
|
|
41
|
-
if (Array.isArray(value)) {
|
|
42
|
-
return '[' + value.map((v) => canonicalize(v)).join(',') + ']';
|
|
43
|
-
}
|
|
44
|
-
if (typeof value === 'object') {
|
|
45
|
-
const obj = value as Record<string, unknown>;
|
|
46
|
-
const keys = Object.keys(obj)
|
|
47
|
-
.filter((k) => obj[k] !== undefined)
|
|
48
|
-
.sort();
|
|
49
|
-
const entries = keys.map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k]));
|
|
50
|
-
return '{' + entries.join(',') + '}';
|
|
51
|
-
}
|
|
52
|
-
return 'null';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Hash a data contract to a deterministic cache key.
|
|
57
|
-
*
|
|
58
|
-
* Hashes intent only — the semantic identity of the UI. Props,
|
|
59
|
-
* actions, stream specs are for code generation, not caching.
|
|
60
|
-
*
|
|
61
|
-
* `intent` is passed separately because `DataContract` itself does
|
|
62
|
-
* not carry an `intent` field — the outer pipeline owns intent
|
|
63
|
-
* (`story.intent` on `ggui_render`, the operator prompt for harness
|
|
64
|
-
* benchmarks). Threading intent into the hash keeps cache identity
|
|
65
|
-
* stable across negotiations: different intents produce different
|
|
66
|
-
* generated code (labels, copy, layout) even when the wire surface is
|
|
67
|
-
* identical.
|
|
68
|
-
*
|
|
69
|
-
* The `contract` argument is retained on the signature for forward
|
|
70
|
-
* compatibility — future cache-key fields (e.g., a structural
|
|
71
|
-
* fingerprint of the four specs) can be folded in without breaking
|
|
72
|
-
* call sites — but is not consulted by the current implementation.
|
|
73
|
-
*
|
|
74
|
-
* @param contract - The data contract from negotiation (unused today)
|
|
75
|
-
* @param intent - The outer pipeline's intent (story.intent on
|
|
76
|
-
* ggui_render). Empty/falsy values are dropped from the hash input.
|
|
77
|
-
* @returns Contract hash prefixed with `ch_` (e.g., `ch_a3f8b2c1e9d04567`)
|
|
78
|
-
*/
|
|
79
|
-
export function hashContract(
|
|
80
|
-
_contract: DataContract,
|
|
81
|
-
intent: string,
|
|
82
|
-
): string {
|
|
83
|
-
const input: Record<string, unknown> = {};
|
|
84
|
-
if (intent) input.intent = intent;
|
|
85
|
-
const canonical = canonicalize(input);
|
|
86
|
-
const hash = createHash('sha256').update(canonical).digest('hex');
|
|
87
|
-
return `ch_${hash.slice(0, 16)}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Build a variant key from shell type and device category.
|
|
92
|
-
*
|
|
93
|
-
* @param shellType - Shell type (default: 'universal')
|
|
94
|
-
* @param deviceCategory - Device category (default: 'universal')
|
|
95
|
-
* @returns Variant string (e.g., 'fullscreen:mobile', 'universal:universal')
|
|
96
|
-
*/
|
|
97
|
-
export function buildVariant(
|
|
98
|
-
shellType = 'universal',
|
|
99
|
-
deviceCategory = 'universal',
|
|
100
|
-
): string {
|
|
101
|
-
return `${shellType}:${deviceCategory}`;
|
|
102
|
-
}
|