@parcha/agentrun-pi 0.1.0-beta.2 → 0.1.0-beta.20

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.
Files changed (114) hide show
  1. package/README.md +104 -27
  2. package/dist/cli.js +15 -4
  3. package/dist/demo-cli.d.ts +2 -0
  4. package/dist/demo-cli.js +25 -0
  5. package/dist/demo.js +1 -0
  6. package/dist/durable/decision-recovery.d.ts +23 -0
  7. package/dist/durable/decision-recovery.js +57 -0
  8. package/dist/durable/decisions.d.ts +113 -0
  9. package/dist/durable/decisions.js +71 -0
  10. package/dist/durable/index.d.ts +20 -0
  11. package/dist/durable/index.js +14 -0
  12. package/dist/durable/node-runner.d.ts +106 -0
  13. package/dist/durable/node-runner.js +253 -0
  14. package/dist/durable/node.d.ts +88 -0
  15. package/dist/durable/node.js +98 -0
  16. package/dist/durable/pi-versions.d.ts +34 -0
  17. package/dist/durable/pi-versions.js +66 -0
  18. package/dist/durable/readers.d.ts +28 -0
  19. package/dist/durable/readers.js +103 -0
  20. package/dist/durable/receipts.d.ts +54 -0
  21. package/dist/durable/receipts.js +92 -0
  22. package/dist/durable/record-file.d.ts +10 -0
  23. package/dist/durable/record-file.js +56 -0
  24. package/dist/durable/record-tool.d.ts +76 -0
  25. package/dist/durable/record-tool.js +123 -0
  26. package/dist/durable/record.d.ts +157 -0
  27. package/dist/durable/record.js +402 -0
  28. package/dist/durable/recording.d.ts +90 -0
  29. package/dist/durable/recording.js +292 -0
  30. package/dist/durable/store.d.ts +23 -0
  31. package/dist/durable/store.js +240 -0
  32. package/dist/durable/task.d.ts +133 -0
  33. package/dist/durable/task.js +411 -0
  34. package/dist/durable/workflow-tool.d.ts +32 -0
  35. package/dist/durable/workflow-tool.js +188 -0
  36. package/dist/extension-service.js +63 -22
  37. package/dist/extension.d.ts +2 -0
  38. package/dist/extension.js +469 -80
  39. package/dist/host-addendum.d.ts +4 -0
  40. package/dist/host-addendum.js +20 -0
  41. package/dist/host-session.js +5 -2
  42. package/dist/index.d.ts +1 -2
  43. package/dist/index.js +1 -2
  44. package/dist/pi-host.d.ts +7 -0
  45. package/dist/pi-host.js +22 -0
  46. package/dist/presentation.js +2 -2
  47. package/dist/run-error.d.ts +6 -0
  48. package/dist/run-error.js +13 -0
  49. package/dist/runner.d.ts +1 -6
  50. package/dist/runner.js +7 -13
  51. package/dist/signin/index.d.ts +9 -0
  52. package/dist/signin/index.js +7 -0
  53. package/dist/signin/limit.d.ts +10 -0
  54. package/dist/signin/limit.js +23 -0
  55. package/dist/signin/resolve.d.ts +47 -0
  56. package/dist/signin/resolve.js +163 -0
  57. package/dist/signin/signin.d.ts +44 -0
  58. package/dist/signin/signin.js +126 -0
  59. package/dist/signin/spec.d.ts +116 -0
  60. package/dist/signin/spec.js +181 -0
  61. package/dist/signin/store.d.ts +40 -0
  62. package/dist/signin/store.js +206 -0
  63. package/dist/skills/author/SKILL.md +138 -0
  64. package/{skills → dist/skills}/author/references/jev-decisions.md +27 -14
  65. package/dist/skills/author/references/language.md +134 -0
  66. package/dist/skills/author/references/sdk-authoring.md +11 -0
  67. package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
  68. package/dist/sop.d.ts +12 -0
  69. package/dist/sop.js +29 -0
  70. package/dist/thinking.d.ts +3 -0
  71. package/dist/thinking.js +7 -0
  72. package/dist/tool-context.d.ts +21 -0
  73. package/dist/tool-context.js +13 -0
  74. package/dist/triage-demo.d.ts +62 -0
  75. package/dist/triage-demo.js +153 -0
  76. package/dist/types.d.ts +7 -0
  77. package/dist/workflow-inspector.d.ts +47 -0
  78. package/dist/workflow-inspector.js +235 -0
  79. package/dist/workflow-session.d.ts +23 -0
  80. package/dist/workflow-session.js +168 -0
  81. package/dist/workflow-store.d.ts +36 -0
  82. package/dist/workflow-store.js +255 -0
  83. package/dist/workflow-tool.d.ts +81 -0
  84. package/dist/workflow-tool.js +94 -0
  85. package/dist/workflow-view.d.ts +64 -0
  86. package/dist/workflow-view.js +273 -0
  87. package/dist/workflows.d.ts +1 -0
  88. package/dist/workflows.js +3 -0
  89. package/package.json +57 -11
  90. package/dist/admission.d.ts +0 -6
  91. package/dist/admission.js +0 -51
  92. package/dist/author.d.ts +0 -21
  93. package/dist/author.js +0 -72
  94. package/dist/benchmark-contract.d.ts +0 -88
  95. package/dist/benchmark-contract.js +0 -50
  96. package/dist/benchmark-freeze.d.ts +0 -9
  97. package/dist/benchmark-freeze.js +0 -9
  98. package/dist/benchmark.d.ts +0 -127
  99. package/dist/benchmark.js +0 -260
  100. package/dist/comparison.d.ts +0 -69
  101. package/dist/comparison.js +0 -229
  102. package/dist/skill-bundle.d.ts +0 -5
  103. package/dist/skill-bundle.js +0 -15
  104. package/skills/author/SKILL.md +0 -102
  105. package/skills/author/references/sdk-authoring.md +0 -16
  106. /package/{skills → dist/skills}/author/examples/evidence-gate.input.json +0 -0
  107. /package/{skills → dist/skills}/author/examples/evidence-gate.json +0 -0
  108. /package/{skills → dist/skills}/author/examples/read-file.input.json +0 -0
  109. /package/{skills → dist/skills}/author/examples/read-file.json +0 -0
  110. /package/{skills → dist/skills}/author/examples/read-source-decision.input.json +0 -0
  111. /package/{skills → dist/skills}/author/examples/read-source-decision.json +0 -0
  112. /package/{skills → dist/skills}/author/examples/release-note.txt +0 -0
  113. /package/{skills → dist/skills}/author/examples/review-release-notes.input.json +0 -0
  114. /package/{skills → dist/skills}/author/examples/review-release-notes.json +0 -0
@@ -0,0 +1,157 @@
1
+ /** The record a node delivered, or null while it has not, and the delivery attempts it has spent. */
2
+ export type RecordState = {
3
+ record: unknown;
4
+ attempts: number;
5
+ };
6
+ /** What the second reading disagrees with in a record: its question, the kind of reading, the verdict and why. */
7
+ export type Disagreement = {
8
+ id: string;
9
+ kind: string;
10
+ verdict: string;
11
+ reasons: string[];
12
+ };
13
+ /** Whether the second reading's round was spent, and what it still disagreed with in the delivered record. */
14
+ export type GateState = {
15
+ bounced: boolean;
16
+ disagreements: Disagreement[];
17
+ };
18
+ /** Delivery attempts a node gets: rejections, the second reading's one bounce and nudges, each once. */
19
+ export declare const DELIVERY_ATTEMPTS = 6;
20
+ /** The most schema violations one rejected delivery lists; the submit tool's description tells the model the number. */
21
+ export declare const VIOLATIONS_SHOWN = 8;
22
+ /** One reviewer's reading of a structurally valid record. `no_verdict` is a reviewer that could not answer. */
23
+ export type Reading = "disagrees" | "agrees" | "no_verdict";
24
+ /** The second reading on one structurally valid submission: it sends the record back only while its round
25
+ * is unspent and a reviewer disagrees. A reviewer with no verdict never sends a record back. */
26
+ export declare function gate(bounced: boolean, readings: readonly Reading[]): "accept" | "bounce";
27
+ /** What a node is answered with. Only `delivered` is a delivery; each of the others spends one attempt. */
28
+ export type DeliveryEvent = "rejected" | "bounce" | "nudge" | "delivered";
29
+ /** A node's delivery, as far as the attempts count it. */
30
+ export type DeliveryCount = {
31
+ attempts: number;
32
+ delivered: boolean;
33
+ };
34
+ /** Whether a node's delivery has ended: it delivered, or its attempts are spent. */
35
+ export declare const deliveryEnded: (max: number, state: DeliveryCount) => boolean;
36
+ /** One answer applied to the count. An ended delivery takes no further answer; every other answer counts once. */
37
+ export declare function spend(max: number, state: DeliveryCount, event: DeliveryEvent): DeliveryCount;
38
+ /** What makes a schema one no `submit` call can satisfy: a call's arguments are an object, so the schema must be a
39
+ * JSON Schema object whose root type, when it names one, admits an object. A node runner refuses such a schema
40
+ * before any conversation exists. */
41
+ export declare function lintRecordRoot(schema: unknown): string[];
42
+ /** What an author should fix in a record schema, for a host or a validator to report: the root findings above, and
43
+ * a schema that declares its fields and requires one that is not among them, which the tool's shown fields never
44
+ * name. A record of the second kind can still be delivered (the tool's parameters are open, and the delivery
45
+ * contract that ends the node's task carries the schema with its `required`), so a runner does not refuse it. A
46
+ * schema that declares no `properties` is not held to it. */
47
+ export declare function lintRecordSchema(schema: unknown): string[];
48
+ /** Where the schema expects an object or an array and the submitted value is a string that parses to one, the
49
+ * parsed value; where it expects a number, an integer, a boolean or null and admits no string, the scalar a
50
+ * string spells. Some model families serialise nested tool arguments as strings: the content is valid and
51
+ * every submit fails on its spelling. A string that parses to a container the field also admits is read as that
52
+ * container; only against a scalar does a field that admits a string keep the string. */
53
+ export declare function parseStringifiedContainers(value: unknown, schema: Record<string, unknown> | undefined, depth?: number, rootDefinitions?: Record<string, unknown>): unknown;
54
+ /** The path of every string that spells "no value" in a field whose schema admits JSON null. */
55
+ export declare function nullSpellings(value: unknown, schema: unknown, root?: unknown, at?: string): string[];
56
+ /** The record with every such string replaced by JSON null, in place. A repair the record as a whole refuses
57
+ * (it satisfied its schema before and does not after) is undone in full, so a spelling stays only where null
58
+ * is inadmissible; `undo: false` leaves that judgement to a caller that repairs more of the record and judges the
59
+ * whole. `""` is a value and stays. */
60
+ export declare function healNullSpellings<T>(record: T, schema: Record<string, unknown>, options?: {
61
+ undo?: boolean;
62
+ }): T;
63
+ /** A record's transport spellings repaired against its schema, without touching `args`. */
64
+ export declare function repairRecord(args: unknown, schema: Record<string, unknown>): unknown;
65
+ /** The file a submission names and nothing else: `{ <key>: "relative/path.json" }`. */
66
+ export declare function fileSubmission(args: unknown, key: string): string | null;
67
+ /** A submission with fields beside the file key. Fields that form a whole record on their own are that record;
68
+ * otherwise the file holds the record, and each field beside it may only restate the file's value. A schema
69
+ * that declares the file key as its own field keeps it inline. */
70
+ export declare function mixedFileSubmission(args: unknown, schema: Record<string, unknown> | undefined, key: string): {
71
+ file: string;
72
+ extras: Record<string, unknown>;
73
+ } | null;
74
+ /** The fields beside the file key that the file's record does not hold with the same value. */
75
+ export declare function extrasNotInRecord(record: unknown, extras: Record<string, unknown>): string[];
76
+ /** One host check's refusal of a candidate: the problems the model must fix, and the reason they are grouped under. */
77
+ export type Refusal = {
78
+ problems: string[];
79
+ reason?: string;
80
+ };
81
+ /** A second reader of a structurally valid record. It returns what it disagrees with (nothing when it agrees or
82
+ * has no verdict). `round` says whether a disagreement still sends the record back. A reviewer that throws
83
+ * decides nothing, unless the contract's `fatal` claims the error. */
84
+ export type Reviewer = (candidate: unknown, context: {
85
+ round: boolean;
86
+ }) => Disagreement[] | Promise<Disagreement[]>;
87
+ export type RecordContract = {
88
+ /** The node's record schema, the `submit` tool's contract. */
89
+ schema: Record<string, unknown>;
90
+ /** Delivery attempts the node gets (default `DELIVERY_ATTEMPTS`). */
91
+ maxAttempts?: number;
92
+ /** A record may be delivered as a workspace file named under `key`; `read` returns the file's parsed JSON or
93
+ * throws (an error whose `code` is `ENOENT` for a file that is not there). Absent, a record is inline only. */
94
+ file?: {
95
+ key: string;
96
+ read(path: string): Promise<unknown>;
97
+ };
98
+ /** Contracts the host owns, held before the schema: each problem is said with the core's own findings. */
99
+ contracts?: (candidate: unknown) => string[] | Promise<string[]>;
100
+ /** Checks held after the schema, in order; the first refusal is the rejection. One that throws refuses with
101
+ * what it threw. */
102
+ checks?: ReadonlyArray<(candidate: unknown) => Refusal | null | Promise<Refusal | null>>;
103
+ /** The second reading, in order. */
104
+ reviewers?: readonly Reviewer[];
105
+ /** Whether a reviewer's error is the caller's own failure, to be thrown and not absorbed. */
106
+ fatal?: (error: unknown) => boolean;
107
+ };
108
+ /** The two states behind a node's delivery. Each write is one atomic change; `reject` and `bounce` return the
109
+ * attempts spent after it. The bounce spends the round before the model is told, so a crash cannot grant it twice. */
110
+ export type RecordStore = {
111
+ read(): Promise<{
112
+ record: RecordState;
113
+ gate: GateState;
114
+ }>;
115
+ reject(): Promise<number>;
116
+ bounce(): Promise<number>;
117
+ accept(record: unknown, disagreements: Disagreement[]): Promise<void>;
118
+ };
119
+ /** What one submission came to. `spent` says the delivery attempts are gone and the node's run ends here. */
120
+ export type Delivery = {
121
+ status: "already";
122
+ } | {
123
+ status: "rejected";
124
+ attempts: number;
125
+ spent: boolean;
126
+ reason: string;
127
+ problems: string[];
128
+ } | {
129
+ status: "bounced";
130
+ attempts: number;
131
+ spent: boolean;
132
+ disagreements: Disagreement[];
133
+ } | {
134
+ status: "accepted";
135
+ record: unknown;
136
+ disagreements: Disagreement[];
137
+ };
138
+ /** Decide one `submit` call. `args` are the call's arguments as the model sent them, or as `repairRecord`
139
+ * already repaired them. */
140
+ export declare function deliver(contract: RecordContract, store: RecordStore, args: unknown): Promise<Delivery>;
141
+ /** The `submit` tool's answer to the model for one delivery. */
142
+ export declare function deliveryText(delivery: Delivery, maxAttempts?: number, say?: (disagreement: Disagreement) => string): string;
143
+ /** One disagreement as the model reads it. */
144
+ export declare const disagreementText: (d: Disagreement) => string;
145
+ /** Whether a delivery ends the node's run: a record was delivered, or the attempts are spent. */
146
+ export declare const deliveryTerminates: (delivery: Delivery) => boolean;
147
+ /** The delivery contract a node's task ends with: the label, the file envelope when one is offered, the
148
+ * authoritative schema and the host's own delivery instructions. The model sees the exact schema only here. */
149
+ export declare function submitFooter(record: {
150
+ label: string;
151
+ schema: Record<string, unknown>;
152
+ fileKey?: string;
153
+ instructions?: string;
154
+ reviewed?: boolean;
155
+ }): string;
156
+ /** What a yield without a record is answered with, when the delivery has an attempt left for it. */
157
+ export declare const nudgeText: (label: string) => string;
@@ -0,0 +1,402 @@
1
+ // The record core: how one LLM node delivers a typed record through one `submit` call, with no pi import.
2
+ //
3
+ // A submission is repaired as a transport spelling (stringified containers and scalars, a "null" where the
4
+ // field admits null), read from a workspace file when it names one, held to the host's contracts and the
5
+ // schema, and then read a second time by the node's reviewers. Every answer that is not a delivery spends
6
+ // one delivery attempt: a rejection, the second reading's one bounce, a nudge of a yield without a record.
7
+ // The second reading is feedback, once: the first record it disagrees with goes back; the next structurally
8
+ // valid record is delivered with what the reading still disagrees with beside it. A reviewer that cannot
9
+ // answer decides nothing.
10
+ //
11
+ // `deliver` is the whole decision over a `RecordStore`, the two small states a caller keeps durably or in
12
+ // memory. `gate` and `spend` are the rules as pure functions, the ones `spec/lean` proves terminate. `deliver`
13
+ // follows `gate`. It does not hold `spend`'s bound: it decides a submission whatever the count, and a nudge that
14
+ // spends the last attempt still continues the run, so the one submission after it is decided and `attempts` can
15
+ // read one past the limit.
16
+ import { isDeepStrictEqual } from "node:util";
17
+ import { Compile } from "typebox/compile";
18
+ /** Delivery attempts a node gets: rejections, the second reading's one bounce and nudges, each once. */
19
+ export const DELIVERY_ATTEMPTS = 6;
20
+ /** The most schema violations one rejected delivery lists; the submit tool's description tells the model the number. */
21
+ export const VIOLATIONS_SHOWN = 8;
22
+ /** The second reading on one structurally valid submission: it sends the record back only while its round
23
+ * is unspent and a reviewer disagrees. A reviewer with no verdict never sends a record back. */
24
+ export function gate(bounced, readings) {
25
+ return !bounced && readings.includes("disagrees") ? "bounce" : "accept";
26
+ }
27
+ /** Whether a node's delivery has ended: it delivered, or its attempts are spent. */
28
+ export const deliveryEnded = (max, state) => state.delivered || state.attempts >= max;
29
+ /** One answer applied to the count. An ended delivery takes no further answer; every other answer counts once. */
30
+ export function spend(max, state, event) {
31
+ if (deliveryEnded(max, state))
32
+ return state;
33
+ return { attempts: state.attempts + 1, delivered: event === "delivered" };
34
+ }
35
+ // ─── the schema a record is held to ───
36
+ /** What makes a schema one no `submit` call can satisfy: a call's arguments are an object, so the schema must be a
37
+ * JSON Schema object whose root type, when it names one, admits an object. A node runner refuses such a schema
38
+ * before any conversation exists. */
39
+ export function lintRecordRoot(schema) {
40
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
41
+ return ["the record schema must be a JSON Schema object"];
42
+ const { type } = schema;
43
+ const admitsObject = type === undefined || type === "object" || (Array.isArray(type) && type.includes("object"));
44
+ return admitsObject ? [] : [`the record schema's root type must be "object", not ${JSON.stringify(type)}`];
45
+ }
46
+ /** What an author should fix in a record schema, for a host or a validator to report: the root findings above, and
47
+ * a schema that declares its fields and requires one that is not among them, which the tool's shown fields never
48
+ * name. A record of the second kind can still be delivered (the tool's parameters are open, and the delivery
49
+ * contract that ends the node's task carries the schema with its `required`), so a runner does not refuse it. A
50
+ * schema that declares no `properties` is not held to it. */
51
+ export function lintRecordSchema(schema) {
52
+ const problems = lintRecordRoot(schema);
53
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
54
+ return problems;
55
+ const { properties, required } = schema;
56
+ const declared = properties && typeof properties === "object" && !Array.isArray(properties) ? properties : null;
57
+ const undeclared = Array.isArray(required) && declared ? required.filter((name) => typeof name !== "string" || !Object.hasOwn(declared, name)) : [];
58
+ if (undeclared.length)
59
+ problems.push(`the record schema requires ${undeclared.map((name) => JSON.stringify(name)).join(", ")}, which its properties do not declare`);
60
+ return problems;
61
+ }
62
+ const validators = new WeakMap();
63
+ /** The schema's compiled validator, or null for a schema the compiler refuses. */
64
+ function validatorOf(schema) {
65
+ if (!schema || typeof schema !== "object")
66
+ return null;
67
+ let validator = validators.get(schema);
68
+ if (validator === undefined) {
69
+ try {
70
+ validator = Compile(schema);
71
+ }
72
+ catch {
73
+ validator = null;
74
+ }
75
+ validators.set(schema, validator);
76
+ }
77
+ return validator;
78
+ }
79
+ // ─── transport repair ───
80
+ /** Where the schema expects an object or an array and the submitted value is a string that parses to one, the
81
+ * parsed value; where it expects a number, an integer, a boolean or null and admits no string, the scalar a
82
+ * string spells. Some model families serialise nested tool arguments as strings: the content is valid and
83
+ * every submit fails on its spelling. A string that parses to a container the field also admits is read as that
84
+ * container; only against a scalar does a field that admits a string keep the string. */
85
+ export function parseStringifiedContainers(value, schema, depth = 0, rootDefinitions) {
86
+ if (depth > 6 || !schema || typeof schema !== "object")
87
+ return value;
88
+ // A `$ref` property carries no type of its own: it is read through the root schema's definitions, or the
89
+ // repair skips exactly the fields it exists for.
90
+ const definitions = rootDefinitions ?? schema.definitions;
91
+ const deref = (spec, hops = 0) => {
92
+ if (hops > 4 || !spec || typeof spec !== "object")
93
+ return spec;
94
+ const ref = typeof spec.$ref === "string" ? spec.$ref.match(/^#\/definitions\/(.+)$/) : null;
95
+ if (ref && definitions && definitions[ref[1]] && typeof definitions[ref[1]] === "object")
96
+ return deref(definitions[ref[1]], hops + 1);
97
+ return spec;
98
+ };
99
+ const expected = (spec) => {
100
+ const resolved = deref(spec);
101
+ const types = Array.isArray(resolved?.type) ? resolved.type : resolved?.type ? [resolved.type] : [];
102
+ const anyOf = Array.isArray(resolved?.anyOf) ? resolved.anyOf : [];
103
+ return [...types, ...anyOf.flatMap((variant) => expected(variant))];
104
+ };
105
+ const repair = (input, rawSpec) => {
106
+ const spec = deref(rawSpec);
107
+ const wants = expected(spec);
108
+ let current = input;
109
+ if (typeof current === "string" && (wants.includes("object") || wants.includes("array"))) {
110
+ const trimmed = current.trim();
111
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
112
+ try {
113
+ const parsed = JSON.parse(trimmed);
114
+ const isObject = parsed && typeof parsed === "object" && !Array.isArray(parsed);
115
+ if ((isObject && wants.includes("object")) || (Array.isArray(parsed) && wants.includes("array")))
116
+ current = parsed;
117
+ }
118
+ catch { /* Not JSON: the string stays for the validator to report. */ }
119
+ }
120
+ }
121
+ // A scalar is coerced only when the field does not accept a string. Integers must be exact; a decimal
122
+ // becomes the float64 that JSON.parse of the same literal yields, the only value a JSON number can hold.
123
+ if (typeof current === "string" && !wants.includes("string")) {
124
+ const trimmed = current.trim();
125
+ if ((wants.includes("integer") || wants.includes("number")) && /^-?\d+$/.test(trimmed) && Number.isSafeInteger(Number(trimmed)))
126
+ current = Number(trimmed);
127
+ else if (wants.includes("number") && /^-?(?:0|[1-9]\d*)\.\d+$/.test(trimmed) && Number.isFinite(Number(trimmed)))
128
+ current = JSON.parse(trimmed);
129
+ else if (wants.includes("boolean") && (trimmed === "true" || trimmed === "false"))
130
+ current = trimmed === "true";
131
+ else if (wants.includes("null") && trimmed === "null")
132
+ current = null;
133
+ }
134
+ // An `anyOf`-wrapped object's nested fields live under the branch's properties, not the wrapper's.
135
+ const flattenBranches = (candidate) => [candidate, ...(Array.isArray(candidate?.anyOf) ? candidate.anyOf.flatMap(flattenBranches) : [])];
136
+ if (current && typeof current === "object" && !Array.isArray(current)) {
137
+ const objectBranch = flattenBranches(spec).map((branch) => deref(branch)).find((branch) => branch?.properties && typeof branch.properties === "object");
138
+ if (objectBranch)
139
+ return parseStringifiedContainers(current, objectBranch, depth + 1, definitions);
140
+ }
141
+ if (Array.isArray(current)) {
142
+ const arrayBranch = flattenBranches(spec).map((branch) => deref(branch)).find((branch) => branch?.items && typeof branch.items === "object");
143
+ if (arrayBranch)
144
+ return current.map((item) => repair(item, arrayBranch.items));
145
+ }
146
+ return current;
147
+ };
148
+ if (!value || typeof value !== "object" || Array.isArray(value))
149
+ return value;
150
+ const properties = schema.properties || {};
151
+ const next = { ...value };
152
+ for (const [key, spec] of Object.entries(properties))
153
+ if (key in next)
154
+ next[key] = repair(next[key], spec);
155
+ return next;
156
+ }
157
+ /** A local `$ref` (`#/definitions/<name>` or `#/$defs/<name>`) resolved against the record's root schema, so
158
+ * the null-spelling walk reads the field schemas the schema check does. One it cannot resolve reads as no schema. */
159
+ function deref(schema, root, hops = 0) {
160
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
161
+ return schema;
162
+ const ref = schema.$ref;
163
+ if (typeof ref !== "string")
164
+ return schema;
165
+ const match = ref.match(/^#\/(definitions|\$defs)\/([^/]+)$/);
166
+ const table = match && root && typeof root === "object" ? root[match[1]] : undefined;
167
+ const target = table && typeof table === "object" ? table[match[2]] : undefined;
168
+ return target && hops < 16 ? deref(target, root, hops + 1) : undefined;
169
+ }
170
+ function admitsNull(schema, root) {
171
+ schema = deref(schema, root);
172
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
173
+ return false;
174
+ const spec = schema;
175
+ if (spec.type === "null" || (Array.isArray(spec.type) && spec.type.includes("null")))
176
+ return true;
177
+ if (spec.const === null || (Array.isArray(spec.enum) && spec.enum.includes(null)))
178
+ return true;
179
+ return [spec.anyOf, spec.oneOf].some((branches) => Array.isArray(branches) && branches.some((branch) => admitsNull(branch, root)));
180
+ }
181
+ function propertiesOf(schema, root) {
182
+ schema = deref(schema, root);
183
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
184
+ return {};
185
+ const spec = schema;
186
+ const fieldMap = !("type" in spec) && !("properties" in spec) && !("anyOf" in spec) && !("oneOf" in spec) ? spec : {};
187
+ const direct = spec.properties && typeof spec.properties === "object" && !Array.isArray(spec.properties) ? spec.properties : fieldMap;
188
+ const branches = [...(Array.isArray(spec.anyOf) ? spec.anyOf : []), ...(Array.isArray(spec.oneOf) ? spec.oneOf : [])];
189
+ return Object.assign({}, ...branches.map((branch) => propertiesOf(branch, root)), direct);
190
+ }
191
+ function itemsOf(schema, root) {
192
+ schema = deref(schema, root);
193
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
194
+ return undefined;
195
+ const spec = schema;
196
+ if (spec.items)
197
+ return spec.items;
198
+ const branches = [...(Array.isArray(spec.anyOf) ? spec.anyOf : []), ...(Array.isArray(spec.oneOf) ? spec.oneOf : [])];
199
+ return branches.map((branch) => itemsOf(branch, root)).find(Boolean);
200
+ }
201
+ // The spellings a model uses when it means "no value": one set, read by the finding and by the repair, so a
202
+ // string one recognises the other heals.
203
+ const NULL_SPELLINGS = new Set(["null", "none", "n/a"]);
204
+ const spellsNull = (value) => typeof value === "string" && NULL_SPELLINGS.has(value.trim().toLowerCase());
205
+ /** The path of every string that spells "no value" in a field whose schema admits JSON null. */
206
+ export function nullSpellings(value, schema, root = schema, at = "") {
207
+ if (spellsNull(value) && admitsNull(schema, root))
208
+ return [at || "$"];
209
+ if (Array.isArray(value)) {
210
+ const items = itemsOf(schema, root);
211
+ return items ? value.flatMap((item, index) => nullSpellings(item, items, root, `${at}[${index}]`)) : [];
212
+ }
213
+ if (!value || typeof value !== "object")
214
+ return [];
215
+ const properties = propertiesOf(schema, root);
216
+ return Object.entries(value).flatMap(([key, child]) => Object.hasOwn(properties, key) ? nullSpellings(child, properties[key], root, at ? `${at}.${key}` : key) : []);
217
+ }
218
+ /** The record with every such string replaced by JSON null, in place. A repair the record as a whole refuses
219
+ * (it satisfied its schema before and does not after) is undone in full, so a spelling stays only where null
220
+ * is inadmissible; `undo: false` leaves that judgement to a caller that repairs more of the record and judges the
221
+ * whole. `""` is a value and stays. */
222
+ export function healNullSpellings(record, schema, options = {}) {
223
+ if (!record || typeof record !== "object" || Array.isArray(record) || Object.getPrototypeOf(record) !== Object.prototype)
224
+ return record;
225
+ if (!nullSpellings(record, schema).length)
226
+ return record;
227
+ const validator = options.undo === false ? null : validatorOf(schema);
228
+ const before = validator?.Check(record) ? structuredClone(record) : null;
229
+ const heal = (value, spec) => {
230
+ if (spellsNull(value) && admitsNull(spec, schema))
231
+ return null;
232
+ if (Array.isArray(value)) {
233
+ const items = itemsOf(spec, schema);
234
+ if (items)
235
+ value.forEach((item, index) => { value[index] = heal(item, items); });
236
+ return value;
237
+ }
238
+ if (!value || typeof value !== "object")
239
+ return value;
240
+ const properties = propertiesOf(spec, schema);
241
+ for (const [key, child] of Object.entries(value)) {
242
+ if (Object.hasOwn(properties, key))
243
+ value[key] = heal(child, properties[key]);
244
+ }
245
+ return value;
246
+ };
247
+ heal(record, schema);
248
+ if (before && validator && !validator.Check(record)) {
249
+ const fields = record;
250
+ for (const key of Object.keys(fields))
251
+ delete fields[key];
252
+ Object.assign(fields, before);
253
+ }
254
+ return record;
255
+ }
256
+ /** A record's transport spellings repaired against its schema, without touching `args`. */
257
+ export function repairRecord(args, schema) {
258
+ if (!args || typeof args !== "object" || Array.isArray(args))
259
+ return args;
260
+ return healNullSpellings(structuredClone(parseStringifiedContainers(args, schema)), schema);
261
+ }
262
+ // ─── the file envelope ───
263
+ /** The file a submission names and nothing else: `{ <key>: "relative/path.json" }`. */
264
+ export function fileSubmission(args, key) {
265
+ if (!args || typeof args !== "object" || Array.isArray(args))
266
+ return null;
267
+ const fields = args;
268
+ return Object.keys(fields).length === 1 && typeof fields[key] === "string" ? fields[key] : null;
269
+ }
270
+ /** A submission with fields beside the file key. Fields that form a whole record on their own are that record;
271
+ * otherwise the file holds the record, and each field beside it may only restate the file's value. A schema
272
+ * that declares the file key as its own field keeps it inline. */
273
+ export function mixedFileSubmission(args, schema, key) {
274
+ if (!args || typeof args !== "object" || Array.isArray(args))
275
+ return null;
276
+ const { [key]: file, ...extras } = args;
277
+ if (typeof file !== "string" || !Object.keys(extras).length)
278
+ return null;
279
+ const properties = schema && typeof schema.properties === "object" && schema.properties ? schema.properties : {};
280
+ return Object.hasOwn(properties, key) ? null : { file, extras };
281
+ }
282
+ /** The fields beside the file key that the file's record does not hold with the same value. */
283
+ export function extrasNotInRecord(record, extras) {
284
+ const held = record && typeof record === "object" && !Array.isArray(record) ? record : {};
285
+ return Object.keys(extras).filter((key) => !Object.hasOwn(held, key) || !isDeepStrictEqual(held[key], extras[key])).sort();
286
+ }
287
+ const SCHEMA_REASON = "submission does not satisfy the schema";
288
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
289
+ /** Decide one `submit` call. `args` are the call's arguments as the model sent them, or as `repairRecord`
290
+ * already repaired them. */
291
+ export async function deliver(contract, store, args) {
292
+ const max = contract.maxAttempts ?? DELIVERY_ATTEMPTS;
293
+ const key = contract.file?.key;
294
+ const state = await store.read();
295
+ if (state.record.record != null)
296
+ return { status: "already" };
297
+ const reject = async (problems, reason = SCHEMA_REASON) => {
298
+ const attempts = await store.reject();
299
+ return { status: "rejected", attempts, spent: attempts >= max, reason, problems };
300
+ };
301
+ const validator = validatorOf(contract.schema);
302
+ let candidate = args;
303
+ const file = key === undefined ? null : fileSubmission(candidate, key);
304
+ const mixed = key === undefined ? null : mixedFileSubmission(candidate, contract.schema, key);
305
+ // Fields beside the file key that are a whole record on their own are that record, as an inline submission.
306
+ const inlineBeside = mixed ? repairRecord(mixed.extras, contract.schema) : undefined;
307
+ if (mixed && validator?.Check(inlineBeside))
308
+ candidate = inlineBeside;
309
+ else if (mixed) {
310
+ const exact = `call submit with exactly {"${key}": ${JSON.stringify(mixed.file)}} and nothing beside it`;
311
+ let record;
312
+ try {
313
+ record = await contract.file.read(mixed.file);
314
+ }
315
+ catch (error) {
316
+ if (error?.code === "ENOENT")
317
+ return reject([`${key} names a file that does not exist in the workspace; write the complete JSON there first, then ${exact}`]);
318
+ return reject([`${messageOf(error)}; then ${exact}`]);
319
+ }
320
+ const stray = extrasNotInRecord(record, mixed.extras);
321
+ if (stray.length) {
322
+ return reject([`the record is read from ${mixed.file} alone, and these fields beside ${key} are missing from that file or differ from it: ${stray.join(", ")}. Write every field into the file, then ${exact}`], "the submission mixes a file with inline fields");
323
+ }
324
+ candidate = repairRecord(record, contract.schema);
325
+ }
326
+ else if (file !== null) {
327
+ // The file's record takes the same transport repair as an inline one.
328
+ try {
329
+ candidate = repairRecord(await contract.file.read(file), contract.schema);
330
+ }
331
+ catch (error) {
332
+ if (error?.code === "ENOENT")
333
+ return reject([`${key} names a file that does not exist in the workspace; write the complete JSON there first, then resubmit`]);
334
+ return reject([messageOf(error)]);
335
+ }
336
+ }
337
+ // A "null" the repair could not heal sits where the record's other fields rule null out: the model writes the value.
338
+ const unhealed = (candidate && typeof candidate === "object" && !Array.isArray(candidate) ? nullSpellings(candidate, contract.schema) : []).map((path) => `${path}: violates "this field cannot be null in this record: the schema admits null for the field alone, but the record's other fields rule it out" — write the real value`);
339
+ const owned = [...unhealed, ...(contract.contracts ? await contract.contracts(candidate) : [])];
340
+ if (owned.length)
341
+ return reject(owned, "the record violates host-owned contracts");
342
+ if (!validator || !validator.Check(candidate)) {
343
+ return reject(validator ? [...validator.Errors(candidate)].slice(0, VIOLATIONS_SHOWN).map((error) => `${error.instancePath || error.path || "/"}: ${error.message}`) : ["the record schema does not compile"]);
344
+ }
345
+ for (const check of contract.checks ?? []) {
346
+ let refusal;
347
+ try {
348
+ refusal = await check(candidate);
349
+ }
350
+ catch (error) {
351
+ refusal = { problems: [`host validation gate threw: ${messageOf(error)}`] };
352
+ }
353
+ if (refusal?.problems.length)
354
+ return reject(refusal.problems, refusal.reason);
355
+ }
356
+ // The second reading, once.
357
+ const round = !state.gate.bounced;
358
+ const disagreements = [];
359
+ for (const reviewer of contract.reviewers ?? []) {
360
+ try {
361
+ disagreements.push(...await reviewer(candidate, { round }));
362
+ }
363
+ catch (error) {
364
+ if (contract.fatal?.(error))
365
+ throw error;
366
+ }
367
+ }
368
+ if (gate(state.gate.bounced, [disagreements.length ? "disagrees" : "agrees"]) === "bounce") {
369
+ const attempts = await store.bounce();
370
+ return { status: "bounced", attempts, spent: attempts >= max, disagreements };
371
+ }
372
+ await store.accept(candidate, disagreements);
373
+ return { status: "accepted", record: candidate, disagreements };
374
+ }
375
+ /** The `submit` tool's answer to the model for one delivery. */
376
+ export function deliveryText(delivery, maxAttempts = DELIVERY_ATTEMPTS, say = disagreementText) {
377
+ const spentLine = "The delivery attempts are spent; the run stops here.";
378
+ switch (delivery.status) {
379
+ case "already": return "Already submitted; the first valid record is authoritative.";
380
+ case "accepted": return "Submitted. You are DONE; end your turn.";
381
+ case "rejected": return `REJECTED (${delivery.attempts}/${maxAttempts}) — ${delivery.reason}: ${delivery.problems.join("; ")}.${delivery.spent ? ` ${spentLine}` : " Fix exactly those and resubmit."}`;
382
+ case "bounced": return `${delivery.disagreements.map(say).join("\n")}${delivery.spent ? `\n${spentLine}` : ""}`;
383
+ }
384
+ }
385
+ /** One disagreement as the model reads it. */
386
+ export const disagreementText = (d) => `${d.id} (${d.kind}): ${d.verdict}${d.reasons.length ? ` - ${d.reasons.join("; ")}` : ""}`;
387
+ /** Whether a delivery ends the node's run: a record was delivered, or the attempts are spent. */
388
+ export const deliveryTerminates = (delivery) => delivery.status === "already" || delivery.status === "accepted" || ((delivery.status === "rejected" || delivery.status === "bounced") && delivery.spent);
389
+ /** The delivery contract a node's task ends with: the label, the file envelope when one is offered, the
390
+ * authoritative schema and the host's own delivery instructions. The model sees the exact schema only here. */
391
+ export function submitFooter(record) {
392
+ return [
393
+ `When you are done, call the \`submit\` tool ONCE with ${record.label} as its arguments. The arguments are validated against the required schema; if validation fails you get the problems back and may fix and resubmit.`,
394
+ ...(record.reviewed ? ["A schema-valid record is then reviewed against the procedure that governs this run before it is accepted; violations come back to you, with the rule each one breaks, to fix and resubmit."] : []),
395
+ ...(record.fileKey ? [`For a large record, write the complete JSON to a fresh file inside the workspace, then call \`submit\` once with {"${record.fileKey}":"relative/path.json"}. The harness reads and validates that file directly; do not paste the record back into the tool call.`] : []),
396
+ `Required JSON Schema (authoritative): ${JSON.stringify(record.schema)}`,
397
+ record.instructions || "",
398
+ "Only a successful `submit` call counts as delivering. Do not answer in plain text.",
399
+ ].filter(Boolean).join("\n");
400
+ }
401
+ /** What a yield without a record is answered with, when the delivery has an attempt left for it. */
402
+ export const nudgeText = (label) => `You stopped without submitting. Call submit with ${label}, complete.`;
@@ -0,0 +1,90 @@
1
+ import type { Session, Storage } from "@earendil-works/pi-durable";
2
+ type Json = any;
3
+ type Context = Parameters<Session["commit"]>[1];
4
+ /** Who owns a line's record, by its kind's package prefix; a kind with no package prefix is the host's. */
5
+ export type RecordingSource = "workflow" | "pi" | "browser" | "vision" | "transport" | "host";
6
+ /** One durable line: a document revision (`data` is `{ doc, key?, value, version }`, the whole value after the
7
+ * revision), a conversation entry (`data` is the entry as stored), or a task's record (`data` as stored). */
8
+ export type RecordingLine = {
9
+ channel: "durable";
10
+ source: RecordingSource;
11
+ /** The document's kind, the entry's own `kind`, or the task's kind. */
12
+ kind: string;
13
+ /** The generation of the latest `agentrun.driver` revision at or before `commit`; absent when there is none. */
14
+ gen?: number;
15
+ /** The store's commit sequence. */
16
+ commit: number;
17
+ /** The line's place inside its commit: documents by id, then entries by id, then tasks by id. */
18
+ order: number;
19
+ /** The workflow execution path the record is about: the one it names, or, for a record in a node attempt's
20
+ * conversation (an entry, a conversation's document, a task), the node's. */
21
+ path?: string;
22
+ /** The pi conversation the record belongs to, when it belongs to one. */
23
+ conv?: number;
24
+ /** Present on a line `recordCommits` wrote: when the process adopted the commit, by the recorder's clock. */
25
+ t?: number;
26
+ /** Present on `snapshotLines`' lines: a document's state at `commit`, not a change made there. */
27
+ snapshot?: true;
28
+ data: Json;
29
+ };
30
+ /**
31
+ * Every change the store `file` committed after `after` (default: all), as durable recording lines in `(commit, order)`
32
+ * order, and `last`, the highest commit returned (`after` when nothing is). For a store no process holds (a disk run's
33
+ * store admits one connection): read-only, in one read transaction.
34
+ *
35
+ * - A document revision is a line of the document's kind, with the whole value after it (each delta applied over its
36
+ * base); `key` is the family key as stored, absent for a singleton.
37
+ * - A conversation entry is a line of the entry's own kind, source `pi`, `conv` its conversation, the entry as stored
38
+ * (image blocks included: the recorder moves them to blobs).
39
+ * - Every task's current record is a line of the task's kind, at the store's last commit, after that commit's other
40
+ * lines, by task id: the state at export, since the store keeps no commit for a task's change. Its memos ride in the
41
+ * record; there are no memo lines.
42
+ * - `gen` is the generation of the latest surviving `agentrun.driver` revision at or before the line's commit (it
43
+ * counts journal opens, not processes), and absent before the earliest surviving one. No line has `t`: the store
44
+ * keeps no commit time.
45
+ *
46
+ * This is the store's surviving history: pi deletes a current-only document's older revisions when it writes a new
47
+ * base. The full history is `recordCommits`', written inside the run's process as each commit lands.
48
+ */
49
+ export declare function recordingLines(file: string, options?: {
50
+ after?: number;
51
+ }): {
52
+ lines: RecordingLine[];
53
+ last: number;
54
+ };
55
+ /**
56
+ * Records the run's commits as durable lines inside its process, on pi's commit publications: each commit adopted
57
+ * after this call becomes the lines `recordingLines` would give it, with tasks at their real commit, `t` the adoption
58
+ * time (`clock()`, default `Date.now()`), and `gen` the generation of the latest `agentrun.driver` revision at or
59
+ * before the commit. `from` is the snapshot this recording continues (`snapshotLines`, written before the run
60
+ * resumed): its driver generation and its nodes' conversations carry on until this process writes its own. `gen` is
61
+ * only for a run with no driver revision yet; it never overrides a driver generation.
62
+ *
63
+ * The listener only maps and queues: it never throws, blocks or calls the Session. `write` receives one commit's lines
64
+ * at a time, in commit order, after the listener has returned. A `write` that completes synchronously (an append to a
65
+ * file) runs before pi adopts the next commit, so a process that dies loses at most the commit in flight; one that
66
+ * completes later leaves the commits after it queued in memory, and a death loses those too. The next process's
67
+ * `snapshotLines` restores the documents' state; a lost commit's entries stay missing, a gap the viewer shows. The
68
+ * returned stop ends the subscription, waits for every queued write, and then throws the first write that failed.
69
+ */
70
+ export declare function recordCommits(session: Pick<Session, "subscribeCommits">, options: {
71
+ gen?: number;
72
+ from?: RecordingLine[];
73
+ write: (lines: RecordingLine[]) => void | Promise<void>;
74
+ clock?: () => number;
75
+ }): () => Promise<void>;
76
+ /**
77
+ * Every document of the run as it stands, as whole values marked `snapshot: true` at commit `at`, in document id order:
78
+ * the state a process records after it opens the run and before it resumes, so the recording is exact after any kill.
79
+ * `storage` is the store the run's Harness was opened over; `at` is the store's latest commit (pi's `Storage` has no
80
+ * head query: a disk run reads it with pi-durable-disk's `storeHead`). The reads are of the current state (pi keeps no
81
+ * past revision of a session or task document), so the snapshot is exact only while nothing commits: take it before
82
+ * the run resumes. Every line's `gen` is the highest `agentrun.driver` generation in it; `gen` is only for a run with no
83
+ * driver revision yet, and never overrides one.
84
+ */
85
+ export declare function snapshotLines(storage: Pick<Storage, "scanConversations" | "scanTasks" | "scanDocuments" | "document">, options: {
86
+ at: number;
87
+ gen?: number;
88
+ context: Context;
89
+ }): Promise<RecordingLine[]>;
90
+ export {};