@aeondave/pi-persona 1.15.2
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/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-persona configuration resolved from environment variables.
|
|
3
|
+
*
|
|
4
|
+
* Pure module — takes an injected env record so it is fully testable. The
|
|
5
|
+
* canonical PI_PERSONA_* names are defined here once.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isThinkingLevel } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export interface PiPersonaConfig {
|
|
11
|
+
disabled: boolean;
|
|
12
|
+
extraDirs: string[];
|
|
13
|
+
defaultPersona?: string;
|
|
14
|
+
keybinding: string;
|
|
15
|
+
persist: boolean;
|
|
16
|
+
delegateDefaultAllow: boolean;
|
|
17
|
+
seed: boolean;
|
|
18
|
+
/** Explicit override for the persisted-state file (else a global default). */
|
|
19
|
+
stateFile?: string;
|
|
20
|
+
/** Thinking level forced on delegated children (else the supervisor's level).
|
|
21
|
+
* Spawned children must carry an *explicit* level or they fall into the model's
|
|
22
|
+
* default mode ("adaptive"), which some models reject. */
|
|
23
|
+
childThinking?: string;
|
|
24
|
+
/** Engine backend for sub-agents: "inproc" (run in-process via `createAgentSession`,
|
|
25
|
+
* the default) or "child" (spawn `pi -p`, the baseline). */
|
|
26
|
+
engine?: "child" | "inproc";
|
|
27
|
+
/** Fast stall/message wakeup interval (ms): while async children run, a tick checks progress but
|
|
28
|
+
* stays SILENT unless a leg NEWLY looks stalled or a sub-agent messaged the supervisor — so a
|
|
29
|
+
* healthy run never interrupts. On by default (30000); PI_PERSONA_PEEK_MS=0 opts out (the routine
|
|
30
|
+
* check-in below is independent). */
|
|
31
|
+
peekEveryMs: number;
|
|
32
|
+
/** Routine check-in interval (ms): how often, while async children run, the idle supervisor gets
|
|
33
|
+
* a compact progress digest so it can catch a leg going off-track (not stalled — just wrong) and
|
|
34
|
+
* step in early. Deliberately slow so it is an occasional glance, not a poll, and independent of
|
|
35
|
+
* the fast stall/message wakeup. 300000 (5 min) by default; PI_PERSONA_CHECKIN_MS=0 opts out. */
|
|
36
|
+
checkInEveryMs: number;
|
|
37
|
+
/** Per-agent hard wall-clock cap (ms): an OPT-IN lifetime ceiling. When set (>0) it settles even
|
|
38
|
+
* a busy-but-non-converging child the idle watchdog (reset on every event) never catches.
|
|
39
|
+
* DISABLED by default (0 = no cap / unlimited) so a healthy, progressing child runs to completion
|
|
40
|
+
* instead of being killed mid-work; the idle watchdog + startup deadline + token budget remain the
|
|
41
|
+
* always-on backstops. Set PI_PERSONA_AGENT_MAX_MS=<ms> to arm a hard cap. */
|
|
42
|
+
agentHardTimeoutMs: number;
|
|
43
|
+
/** Per-agent STARTUP deadline (ms): a child that produces no progress of its own (no completed
|
|
44
|
+
* turn / tokens / streamed assistant output) within this window is killed as a stalled start.
|
|
45
|
+
* It exists because the idle watchdog cannot catch a hung start — that watchdog is re-armed by
|
|
46
|
+
* ANY stdout, and a child emits header/noise before it does any work.
|
|
47
|
+
*
|
|
48
|
+
* The window must cover the WHOLE cold start, not just init: spawn, pi init, MCP init, and the
|
|
49
|
+
* first provider response. A queued or rate-limited provider (a 429 carrying a 60s
|
|
50
|
+
* retry-after, then retried), a cold serverless endpoint, or a local model doing a long prompt
|
|
51
|
+
* eval on CPU can all exceed a tight window while perfectly healthy — and this deadline KILLS,
|
|
52
|
+
* where the idle watchdog would have let the leg live. Killing healthy work costs the user
|
|
53
|
+
* more than a dead leg settling slowly, so the default is deliberately generous.
|
|
54
|
+
* 300000 (5 min) by default; PI_PERSONA_AGENT_STARTUP_MS=0 disables it. */
|
|
55
|
+
agentStartupTimeoutMs: number;
|
|
56
|
+
/** Delegation nudge: when a delegating supervisor grinds a RUN of hands-on commands by hand (a
|
|
57
|
+
* by-hand sweep) without a hand-off, append a reminder to the offending tool's result. On by
|
|
58
|
+
* default; PI_PERSONA_NUDGE=off opts out. */
|
|
59
|
+
nudge: boolean;
|
|
60
|
+
/** Cross-process broker (spec B1-B7): on by default so child-engine legs (MCP, worktree,
|
|
61
|
+
* `PI_PERSONA_ENGINE=child`) expose the same steer/contact_supervisor plane in-process
|
|
62
|
+
* runs already have. The host still starts lazily on the first child-engine build.
|
|
63
|
+
* PI_PERSONA_BROKER=off restores pre-broker spawn env (no host, no extra env vars). */
|
|
64
|
+
broker: boolean;
|
|
65
|
+
/** External agent-to-agent plane (exocom): independent top-level instances in one workspace
|
|
66
|
+
* discover + message each other. Opt-in, OFF by default (PI_PERSONA_EXOCOM=1 / --exocom),
|
|
67
|
+
* additionally gated by the persona's canUseBus. */
|
|
68
|
+
exocom: boolean;
|
|
69
|
+
/** How many settled-but-uncollected async runs {@link AsyncRunTracker} retains before
|
|
70
|
+
* FIFO-evicting the oldest completed ones. 25 by default (today's hardcoded bound); set
|
|
71
|
+
* PI_PERSONA_ASYNC_RETAIN=<n> (a finite value >= 1) to raise it under heavy fan-out. A
|
|
72
|
+
* value < 1 (junk, negative, or 0 — not a meaningful retention bound) keeps the default. */
|
|
73
|
+
asyncRetain: number;
|
|
74
|
+
/** Opt-in wider {@link DelegationLedger} veto key: OFF (default) keys purely on
|
|
75
|
+
* `agent+model+task` (today's veto *behavior* — unchanged; the key bytes themselves have
|
|
76
|
+
* moved on, NUL → space → `\x1f`, but record/vet always share the same key() so it
|
|
77
|
+
* cancels out). ON additionally folds in `role`/`tools`/`isolation`, so a genuine retry
|
|
78
|
+
* that only changes those isn't falsely vetoed as "identical" by the 2-strike anti-loop
|
|
79
|
+
* guard. Truthy convention mirrors exocom — see {@link OFF_WORDS}. */
|
|
80
|
+
ledgerV2: boolean;
|
|
81
|
+
/** Source selector for the spine — the shared behavioral layer injected between Pi's base
|
|
82
|
+
* prompt and the persona body (docs/SPINE.md). `""` ⇒ off (the default; composition stays
|
|
83
|
+
* byte-identical to pre-spine), `"on"` ⇒ the user-dir `spine.md` if present else the
|
|
84
|
+
* bundled one, anything else ⇒ that path. Off convention mirrors exocom (see
|
|
85
|
+
* {@link OFF_WORDS}); the value is otherwise kept VERBATIM because it is a path. */
|
|
86
|
+
spine: string;
|
|
87
|
+
/** Source selector for the spine DELEGATED LEGS get — the worker variant, a different prompt
|
|
88
|
+
* doing a different job. Same grammar as {@link PiPersonaConfig.spine}, and it FOLLOWS that
|
|
89
|
+
* value unless PI_PERSONA_SPINE_LEGS is set: one switch still lifts both roles, while the two
|
|
90
|
+
* together express the four measurement arms of docs/SPINE.md (off / supervisor-only /
|
|
91
|
+
* legs-only / both), which one selector cannot. */
|
|
92
|
+
spineLegs: string;
|
|
93
|
+
/** Files-changed report for a non-worktree leg (`engine/change-report.ts`): on by default,
|
|
94
|
+
* matching the existing behavior. Costs up to two `git status` spawns per leg (one at launch,
|
|
95
|
+
* one at settle) with no per-leg way to skip it otherwise — a large monorepo running many
|
|
96
|
+
* parallel legs may want to opt out. Off-word convention mirrors `broker`/`exocom`
|
|
97
|
+
* ({@link OFF_WORDS}); PI_PERSONA_LEG_CHANGE_REPORT=off/0/false disables it. */
|
|
98
|
+
changeReport: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type Env = Record<string, string | undefined>;
|
|
102
|
+
|
|
103
|
+
/** The words that mean "off" for an opt-in switch. `false`/`no` are in here because they are
|
|
104
|
+
* what a user actually types: without them `PI_PERSONA_EXOCOM=false` reads as truthy and
|
|
105
|
+
* `PI_PERSONA_SPINE=false` is reinterpreted as a relative file PATH. Exported (read-only) so
|
|
106
|
+
* the `{@link OFF_WORDS}` references on the switches above resolve for a reader holding only
|
|
107
|
+
* the emitted types — a link into a module-private const documents nothing outside this file. */
|
|
108
|
+
export const OFF_WORDS: ReadonlySet<string> = new Set(["", "off", "0", "false", "no"]);
|
|
109
|
+
|
|
110
|
+
/** The words that mean "on". Symmetric with {@link OFF_WORDS} and needed for the same reason: a
|
|
111
|
+
* switch whose value can also be a PATH cannot treat "not off" as "on", so `1`/`true`/`yes` —
|
|
112
|
+
* which the broker's own docs teach users to type — would otherwise be resolved as relative
|
|
113
|
+
* filenames. */
|
|
114
|
+
export const ON_WORDS: ReadonlySet<string> = new Set(["on", "1", "true", "yes"]);
|
|
115
|
+
|
|
116
|
+
/** One definition of "off", shared by the switches whose value is a plain flag or a path. The
|
|
117
|
+
* older boolean switches (`persist`, `nudge`, `seed`, `disabled`, `delegateDefaultAllow`)
|
|
118
|
+
* predate it and keep their own published conventions — changing those would alter what an
|
|
119
|
+
* existing user's environment means. Unset counts as off. `broker` uses this helper with a
|
|
120
|
+
* default-on pin (`PI_PERSONA_BROKER ?? "on"`). */
|
|
121
|
+
function isOff(value: string | undefined): boolean {
|
|
122
|
+
return OFF_WORDS.has((value ?? "").trim().toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The spine selectors' shared value grammar: an off word ⇒ `""`, an on word ⇒ `"on"`, anything
|
|
126
|
+
* else is a PATH. Only the SELECTOR words are matched case-insensitively; a path survives
|
|
127
|
+
* verbatim, case included, because filesystems are case-sensitive. */
|
|
128
|
+
function spineSelector(raw: string): string {
|
|
129
|
+
if (isOff(raw)) return "";
|
|
130
|
+
return ON_WORDS.has(raw.trim().toLowerCase()) ? "on" : raw;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function splitDirs(value: string | undefined): string[] {
|
|
134
|
+
if (!value) return [];
|
|
135
|
+
return value
|
|
136
|
+
.split(/[;,]/)
|
|
137
|
+
.map((s) => s.trim())
|
|
138
|
+
.filter((s) => s.length > 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveConfig(env: Env): PiPersonaConfig {
|
|
142
|
+
const disableFlag = env.PI_PERSONA_DISABLE;
|
|
143
|
+
const def = env.PI_PERSONA_DEFAULT?.trim();
|
|
144
|
+
|
|
145
|
+
const config: PiPersonaConfig = {
|
|
146
|
+
disabled: disableFlag !== undefined && disableFlag.length > 0,
|
|
147
|
+
extraDirs: splitDirs(env.PI_PERSONA_DIRS),
|
|
148
|
+
keybinding: env.PI_PERSONA_KEY?.trim() || "f8",
|
|
149
|
+
persist: env.PI_PERSONA_PERSIST?.trim().toLowerCase() !== "off",
|
|
150
|
+
// "deny" locks the delegate roster down; the shared OFF_WORDS are accepted as the same
|
|
151
|
+
// request (additive — "off" previously meant default-ALLOW, a security default a user
|
|
152
|
+
// could believe they had tightened while changing nothing).
|
|
153
|
+
delegateDefaultAllow: (() => {
|
|
154
|
+
const v = env.PI_PERSONA_DELEGATE_DEFAULT?.trim().toLowerCase();
|
|
155
|
+
if (v === undefined || v === "") return true; // unset stays default-allow
|
|
156
|
+
return v !== "deny" && !OFF_WORDS.has(v);
|
|
157
|
+
})(),
|
|
158
|
+
// Opt-in: auto-install the bundled defaults on first run ONLY when explicitly enabled with
|
|
159
|
+
// `PI_PERSONA_SEED=on`. Default off — personas are installed via `/persona seed|restore`.
|
|
160
|
+
seed: env.PI_PERSONA_SEED?.trim().toLowerCase() === "on",
|
|
161
|
+
peekEveryMs: 30_000,
|
|
162
|
+
checkInEveryMs: 300_000,
|
|
163
|
+
// Opt-in: 0 = no hard cap (a healthy child runs to completion). The idle watchdog
|
|
164
|
+
// (RUN_LIMITS.timeoutMs, reset on progress) + startup deadline + token budget are the
|
|
165
|
+
// always-on backstops; set PI_PERSONA_AGENT_MAX_MS=<ms> to arm a wall-clock ceiling.
|
|
166
|
+
agentHardTimeoutMs: 0,
|
|
167
|
+
agentStartupTimeoutMs: 300_000,
|
|
168
|
+
// On unless explicitly turned off (mirrors PI_PERSONA_PERSIST's `!== "off"` convention).
|
|
169
|
+
nudge: env.PI_PERSONA_NUDGE?.trim().toLowerCase() !== "off",
|
|
170
|
+
// On unless explicitly turned off, so an MCP/worktree/child-engine async leg is
|
|
171
|
+
// steerable without a hidden env pin. Off-words restore pre-broker spawn env.
|
|
172
|
+
broker: !isOff(env.PI_PERSONA_BROKER ?? "on"),
|
|
173
|
+
exocom: false,
|
|
174
|
+
asyncRetain: 25,
|
|
175
|
+
ledgerV2: false,
|
|
176
|
+
spine: "",
|
|
177
|
+
spineLegs: "",
|
|
178
|
+
changeReport: !isOff(env.PI_PERSONA_LEG_CHANGE_REPORT ?? "on"),
|
|
179
|
+
};
|
|
180
|
+
// A valid finite value >= 0 sets the interval (0 opts out); junk/negative keeps the default.
|
|
181
|
+
const peekRaw = env.PI_PERSONA_PEEK_MS?.trim();
|
|
182
|
+
if (peekRaw !== undefined && peekRaw !== "") {
|
|
183
|
+
const peek = Number(peekRaw);
|
|
184
|
+
if (Number.isFinite(peek) && peek >= 0) config.peekEveryMs = peek;
|
|
185
|
+
}
|
|
186
|
+
const checkInRaw = env.PI_PERSONA_CHECKIN_MS?.trim();
|
|
187
|
+
if (checkInRaw !== undefined && checkInRaw !== "") {
|
|
188
|
+
const checkIn = Number(checkInRaw);
|
|
189
|
+
if (Number.isFinite(checkIn) && checkIn >= 0) config.checkInEveryMs = checkIn;
|
|
190
|
+
}
|
|
191
|
+
const hardRaw = env.PI_PERSONA_AGENT_MAX_MS?.trim();
|
|
192
|
+
if (hardRaw !== undefined && hardRaw !== "") {
|
|
193
|
+
const hard = Number(hardRaw);
|
|
194
|
+
if (Number.isFinite(hard) && hard >= 0) config.agentHardTimeoutMs = hard;
|
|
195
|
+
}
|
|
196
|
+
const startupRaw = env.PI_PERSONA_AGENT_STARTUP_MS?.trim();
|
|
197
|
+
if (startupRaw !== undefined && startupRaw !== "") {
|
|
198
|
+
const startup = Number(startupRaw);
|
|
199
|
+
if (Number.isFinite(startup) && startup >= 0) config.agentStartupTimeoutMs = startup;
|
|
200
|
+
}
|
|
201
|
+
// A finite value >= 1 sets the retention bound; 0 isn't meaningful here (unlike the interval
|
|
202
|
+
// knobs above, where 0 opts out) so it — like junk/negative — falls back to the default 25.
|
|
203
|
+
const asyncRetainRaw = env.PI_PERSONA_ASYNC_RETAIN?.trim();
|
|
204
|
+
if (asyncRetainRaw !== undefined && asyncRetainRaw !== "") {
|
|
205
|
+
const retain = Number(asyncRetainRaw);
|
|
206
|
+
if (Number.isFinite(retain) && retain >= 1) config.asyncRetain = retain;
|
|
207
|
+
}
|
|
208
|
+
if (def) config.defaultPersona = def;
|
|
209
|
+
const stateFile = env.PI_PERSONA_STATE_FILE?.trim();
|
|
210
|
+
if (stateFile) config.stateFile = stateFile;
|
|
211
|
+
const childThinking = env.PI_PERSONA_CHILD_THINKING?.trim().toLowerCase();
|
|
212
|
+
if (childThinking && isThinkingLevel(childThinking)) config.childThinking = childThinking;
|
|
213
|
+
// In-process is the default backend; opt back to the spawn-based child engine with
|
|
214
|
+
// PI_PERSONA_ENGINE=child.
|
|
215
|
+
const engine = env.PI_PERSONA_ENGINE?.trim().toLowerCase();
|
|
216
|
+
config.engine = engine === "child" ? "child" : "inproc";
|
|
217
|
+
config.exocom = !isOff(env.PI_PERSONA_EXOCOM);
|
|
218
|
+
config.ledgerV2 = !isOff(env.PI_PERSONA_LEDGER_V2);
|
|
219
|
+
config.spine = spineSelector(env.PI_PERSONA_SPINE?.trim() ?? "");
|
|
220
|
+
// The legs' selector FOLLOWS the supervisor's unless it is set, so one switch still lifts both
|
|
221
|
+
// roles while the pair expresses all four measurement arms (docs/SPINE.md). Present-but-empty
|
|
222
|
+
// counts as UNSET here rather than as the off word it is elsewhere: `PI_PERSONA_SPINE_LEGS=` is
|
|
223
|
+
// how a shell clears a variable, and an inherited empty splitting the legs off from the
|
|
224
|
+
// supervisor would be a silent, invisible arm nobody chose.
|
|
225
|
+
const legsRaw = env.PI_PERSONA_SPINE_LEGS?.trim();
|
|
226
|
+
config.spineLegs = legsRaw ? spineSelector(legsRaw) : config.spine;
|
|
227
|
+
return config;
|
|
228
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured return contracts — the substrate that makes voting/judging
|
|
3
|
+
* mechanical (reducers read fields generically) and that is PINNED per run.
|
|
4
|
+
*
|
|
5
|
+
* Pure module (Node stdlib only, no Pi imports). A contract is a small typed
|
|
6
|
+
* schema; `pinContract` freezes a `name@hash` for a run so hot-reloading a
|
|
7
|
+
* contract file never changes an in-flight run (guardrails I3).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
export type FieldType = "string" | "number" | "boolean" | "enum";
|
|
13
|
+
|
|
14
|
+
export interface FieldSpec {
|
|
15
|
+
type: FieldType;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
/** Allowed values for `type: "enum"`. */
|
|
18
|
+
values?: string[];
|
|
19
|
+
/** Inclusive bounds for `type: "number"`. */
|
|
20
|
+
min?: number;
|
|
21
|
+
max?: number;
|
|
22
|
+
/** Minimum string length for `type: "string"` — a non-empty / substantive gate (e.g. a proof
|
|
23
|
+
* line that must carry a real command + live output, not an empty placeholder). */
|
|
24
|
+
minLength?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ContractDef {
|
|
28
|
+
name: string;
|
|
29
|
+
fields: Record<string, FieldSpec>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PinnedContract {
|
|
33
|
+
name: string;
|
|
34
|
+
hash: string;
|
|
35
|
+
def: ContractDef;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ValidationResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
errors: string[];
|
|
41
|
+
/** The validated object (present only when `ok`). */
|
|
42
|
+
value?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The built-in default contract: pi-fork's four sections + voting substrate. */
|
|
46
|
+
export const DEFAULT_CONTRACT: ContractDef = {
|
|
47
|
+
name: "default",
|
|
48
|
+
fields: {
|
|
49
|
+
result: { type: "string", required: true },
|
|
50
|
+
output: { type: "string" },
|
|
51
|
+
evidence: { type: "string" },
|
|
52
|
+
learnings: { type: "string" },
|
|
53
|
+
vote: { type: "string" },
|
|
54
|
+
confidence: { type: "number", min: 0, max: 1 },
|
|
55
|
+
stance: { type: "enum", values: ["approve", "reject", "revise"] },
|
|
56
|
+
dissent: { type: "string" },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const FIELD_TYPES: readonly FieldType[] = ["string", "number", "boolean", "enum"];
|
|
61
|
+
|
|
62
|
+
export type ContractParse = { ok: true; def: ContractDef } | { ok: false; error: string };
|
|
63
|
+
|
|
64
|
+
/** Parse a `contracts/<name>.contract.json` file into a validated {@link ContractDef}.
|
|
65
|
+
* Data-driven authoring surface (spec §4.6): drop a JSON file, reference it by name via a
|
|
66
|
+
* persona/strategy `outputContract`. Reducers then read its fields generically. */
|
|
67
|
+
export function parseContract(content: string): ContractParse {
|
|
68
|
+
let raw: unknown;
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(content);
|
|
71
|
+
} catch {
|
|
72
|
+
return { ok: false, error: "contract is not valid JSON" };
|
|
73
|
+
}
|
|
74
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ok: false, error: "contract must be a JSON object" };
|
|
75
|
+
const o = raw as Record<string, unknown>;
|
|
76
|
+
if (typeof o.name !== "string" || !o.name.trim()) return { ok: false, error: "contract.name is required" };
|
|
77
|
+
if (typeof o.fields !== "object" || o.fields === null || Array.isArray(o.fields)) return { ok: false, error: "contract.fields must be an object" };
|
|
78
|
+
|
|
79
|
+
const fields: Record<string, FieldSpec> = {};
|
|
80
|
+
for (const [fname, rawSpec] of Object.entries(o.fields as Record<string, unknown>)) {
|
|
81
|
+
if (typeof rawSpec !== "object" || rawSpec === null || Array.isArray(rawSpec)) return { ok: false, error: `field "${fname}" must be an object` };
|
|
82
|
+
const s = rawSpec as Record<string, unknown>;
|
|
83
|
+
if (typeof s.type !== "string" || !(FIELD_TYPES as readonly string[]).includes(s.type)) {
|
|
84
|
+
return { ok: false, error: `field "${fname}" has an invalid type (string|number|boolean|enum)` };
|
|
85
|
+
}
|
|
86
|
+
const spec: FieldSpec = { type: s.type as FieldType };
|
|
87
|
+
if (s.required === true) spec.required = true;
|
|
88
|
+
if (spec.type === "enum") {
|
|
89
|
+
if (!Array.isArray(s.values) || !s.values.every((v): v is string => typeof v === "string") || s.values.length === 0) {
|
|
90
|
+
return { ok: false, error: `enum field "${fname}" needs a non-empty string "values" list` };
|
|
91
|
+
}
|
|
92
|
+
spec.values = s.values;
|
|
93
|
+
}
|
|
94
|
+
if (typeof s.min === "number") spec.min = s.min;
|
|
95
|
+
if (typeof s.max === "number") spec.max = s.max;
|
|
96
|
+
if (typeof s.minLength === "number") spec.minLength = s.minLength;
|
|
97
|
+
fields[fname] = spec;
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, def: { name: o.name, fields } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Does this text parse as JSON? The only honest test of a candidate. */
|
|
103
|
+
function parses(candidate: string): boolean {
|
|
104
|
+
if (candidate.length === 0) return false;
|
|
105
|
+
try {
|
|
106
|
+
JSON.parse(candidate);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every fenced code block in the text, last one first. The contract asks for the JSON at
|
|
115
|
+
* the END ("prose before it is fine, nothing after it"), so the last fence is the answer
|
|
116
|
+
* and any earlier fence is quoted evidence in the report.
|
|
117
|
+
*/
|
|
118
|
+
function fencedBlocks(s: string): string[] {
|
|
119
|
+
const out: string[] = [];
|
|
120
|
+
const re = /```[^\n`]*\n([\s\S]*?)```/g;
|
|
121
|
+
let m = re.exec(s);
|
|
122
|
+
while (m !== null) {
|
|
123
|
+
if (m[1] !== undefined) out.push(m[1].trim());
|
|
124
|
+
m = re.exec(s);
|
|
125
|
+
}
|
|
126
|
+
return out.reverse();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scan backwards from one closer for a BALANCED object/array: walk from `close` to its
|
|
131
|
+
* matching opener, tracking string state so a brace inside a string value never miscounts.
|
|
132
|
+
* This is what survives prose that itself contains braces (a report quoting code or a
|
|
133
|
+
* `${…}`), where a naive first-`{` scan slices from the prose and yields garbage.
|
|
134
|
+
*/
|
|
135
|
+
function balancedFrom(s: string, close: number): string | undefined {
|
|
136
|
+
const open = s[close] === "}" ? "{" : "[";
|
|
137
|
+
const shut = s[close];
|
|
138
|
+
let depth = 0;
|
|
139
|
+
let inString = false;
|
|
140
|
+
let escaped = false;
|
|
141
|
+
// Walk backwards. `escaped` needs a forward look: a quote is literal when preceded by an
|
|
142
|
+
// ODD number of backslashes, so count them rather than tracking a single flag.
|
|
143
|
+
for (let i = close; i >= 0; i--) {
|
|
144
|
+
const ch = s[i];
|
|
145
|
+
if (ch === '"') {
|
|
146
|
+
let backslashes = 0;
|
|
147
|
+
for (let j = i - 1; j >= 0 && s[j] === "\\"; j--) backslashes++;
|
|
148
|
+
escaped = backslashes % 2 === 1;
|
|
149
|
+
if (!escaped) inString = !inString;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (inString) continue;
|
|
153
|
+
if (ch === shut) depth++;
|
|
154
|
+
else if (ch === open) {
|
|
155
|
+
depth--;
|
|
156
|
+
if (depth === 0) return s.slice(i, close + 1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Candidates are cheap; the cap only bounds pathological prose dense with closers. */
|
|
163
|
+
const MAX_JSON_CANDIDATES = 64;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Every balanced object/array in the text, walking closers RIGHT-TO-LEFT (the contract
|
|
167
|
+
* asks for the answer at the end). One candidate per closer is not enough: trailing prose
|
|
168
|
+
* like "option [2]" or "Done {thanks}." yields a slice that parses (or mis-slices into
|
|
169
|
+
* prose) and must not shadow the real answer object sitting behind it.
|
|
170
|
+
*/
|
|
171
|
+
function* balancedFromEnd(s: string): Generator<string> {
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
let yielded = 0;
|
|
174
|
+
for (let close = s.length - 1; close >= 0 && yielded < MAX_JSON_CANDIDATES; close--) {
|
|
175
|
+
const ch = s[close];
|
|
176
|
+
if (ch !== "}" && ch !== "]") continue;
|
|
177
|
+
const slice = balancedFrom(s, close);
|
|
178
|
+
if (slice !== undefined && !seen.has(slice)) {
|
|
179
|
+
seen.add(slice);
|
|
180
|
+
yielded++;
|
|
181
|
+
yield slice;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* All JSON candidates of a reply, most-specific first, deduped: the whole text, each
|
|
188
|
+
* fenced block last-first (plus the balanced objects inside a fence that holds prose
|
|
189
|
+
* around the JSON), then every balanced object/array of the whole text right-to-left,
|
|
190
|
+
* and finally the legacy outermost slice (error shaping only — it may not parse).
|
|
191
|
+
*/
|
|
192
|
+
function* jsonCandidates(text: string): Generator<string> {
|
|
193
|
+
const seen = new Set<string>();
|
|
194
|
+
const emit = function* (c: string | undefined): Generator<string> {
|
|
195
|
+
if (c !== undefined && c.length > 0 && !seen.has(c)) {
|
|
196
|
+
seen.add(c);
|
|
197
|
+
yield c;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const s = text.trim();
|
|
201
|
+
yield* emit(s);
|
|
202
|
+
for (const block of fencedBlocks(s)) {
|
|
203
|
+
yield* emit(block);
|
|
204
|
+
yield* balancedFromEnd(block);
|
|
205
|
+
}
|
|
206
|
+
yield* balancedFromEnd(s);
|
|
207
|
+
// Legacy fallback: outermost slice. If nothing parsed, this only shapes the error.
|
|
208
|
+
const starts = [s.indexOf("{"), s.indexOf("[")].filter((i) => i >= 0);
|
|
209
|
+
if (starts.length > 0) {
|
|
210
|
+
const start = Math.min(...starts);
|
|
211
|
+
const end = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
|
|
212
|
+
if (end > start) yield* emit(s.slice(start, end + 1));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Pull a JSON candidate out of an LLM reply before parsing. Models very often wrap
|
|
218
|
+
* structured output in a ```json fence or surround it with prose ("Here you go: {…}"),
|
|
219
|
+
* which makes a raw `JSON.parse` throw and silently fails the whole contract (the magi
|
|
220
|
+
* bug: every member's vote excluded as "invalid output").
|
|
221
|
+
*
|
|
222
|
+
* Candidates are tried most-specific first and the FIRST ONE THAT ACTUALLY PARSES wins,
|
|
223
|
+
* so a heuristic can never win by producing confident garbage — with one refinement:
|
|
224
|
+
* contracts always want a JSON OBJECT, so among parseable candidates an object beats an
|
|
225
|
+
* array/scalar (trailing prose like "option [2]" parses as JSON but can never be the
|
|
226
|
+
* answer; the real object behind it is).
|
|
227
|
+
* 1. the whole text (already-clean JSON);
|
|
228
|
+
* 2. each fenced block, last fence first (the contract puts the answer last, so an
|
|
229
|
+
* earlier fence is quoted evidence);
|
|
230
|
+
* 3. every balanced object/array scanned backwards, string-aware, right-to-left.
|
|
231
|
+
* Falls back to the legacy outermost-slice so genuinely-malformed output still fails
|
|
232
|
+
* parsing naturally with the same shape of error as before.
|
|
233
|
+
*
|
|
234
|
+
* Regressions this closes: a report whose PROSE contains a brace (quoted code, a `${…}`)
|
|
235
|
+
* followed by the fenced answer; and a correct answer followed by prose carrying its own
|
|
236
|
+
* bracketed token ("I picked option [2]"), which used to shadow the real object and fail
|
|
237
|
+
* a leg whose work was correct ("contract <name> failed: output was not valid JSON").
|
|
238
|
+
*/
|
|
239
|
+
export function extractJsonCandidate(text: string): string {
|
|
240
|
+
let firstParseable: string | undefined;
|
|
241
|
+
for (const candidate of jsonCandidates(text)) {
|
|
242
|
+
if (!parses(candidate)) continue;
|
|
243
|
+
if (candidate.startsWith("{")) return candidate;
|
|
244
|
+
firstParseable ??= candidate;
|
|
245
|
+
}
|
|
246
|
+
if (firstParseable !== undefined) return firstParseable;
|
|
247
|
+
// Nothing parsed: return the last (legacy outermost-slice) candidate if it exists,
|
|
248
|
+
// else the trimmed text — both only shape the downstream parse error.
|
|
249
|
+
let last: string | undefined;
|
|
250
|
+
for (const candidate of jsonCandidates(text)) last = candidate;
|
|
251
|
+
return last ?? text.trim();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One human-readable ballot line for a field: `- vote (string, required, one of: a | b)`. */
|
|
255
|
+
function fieldLine(name: string, spec: FieldSpec): string {
|
|
256
|
+
const parts: string[] = [spec.type];
|
|
257
|
+
if (spec.required) parts.push("required");
|
|
258
|
+
if (spec.type === "enum" && spec.values) parts.push(`one of: ${spec.values.join(" | ")}`);
|
|
259
|
+
if (spec.min !== undefined && spec.max !== undefined) parts.push(`${spec.min}..${spec.max}`);
|
|
260
|
+
else if (spec.min !== undefined) parts.push(`>= ${spec.min}`);
|
|
261
|
+
else if (spec.max !== undefined) parts.push(`<= ${spec.max}`);
|
|
262
|
+
if (spec.minLength !== undefined) parts.push(`min ${spec.minLength} chars`);
|
|
263
|
+
return `- ${name} (${parts.join(", ")})`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The task preamble that tells a member HOW to satisfy `outputContract` — derived
|
|
268
|
+
* mechanically from the (pinned) def and appended by BOTH engine backends whenever a
|
|
269
|
+
* spec carries a contract. Without this, only agents whose own .md hand-writes the JSON
|
|
270
|
+
* format (e.g. the MAGI cores) could be convened by a voting strategy; a generic
|
|
271
|
+
* executor would answer in prose and be quarantined as an invalid output. Live-drive
|
|
272
|
+
* verified: debate over two bare `operator`s fails exactly that way without it.
|
|
273
|
+
*/
|
|
274
|
+
export function contractInstructions(def: ContractDef): string {
|
|
275
|
+
const lines = [
|
|
276
|
+
`--- output contract (${def.name}) ---`,
|
|
277
|
+
"End your FINAL answer with a single JSON object (prose before it is fine, nothing after it) with these fields:",
|
|
278
|
+
...Object.entries(def.fields).map(([n, s]) => fieldLine(n, s)),
|
|
279
|
+
"The object is parsed mechanically. Include every required field; omit fields you have nothing for.",
|
|
280
|
+
];
|
|
281
|
+
if (def.fields.vote) {
|
|
282
|
+
// Vote hygiene (the MAGI cores hand-write this in their .md; a generic agent needs it
|
|
283
|
+
// here): qualifiers make equivalent votes tally apart ("esm" vs "esm (but only if…)").
|
|
284
|
+
lines.push(
|
|
285
|
+
'For "vote": the BARE option token only — for "X or Y?" vote "x" or "y", no qualifiers or conditions (put nuance in result/output) so equivalent votes tally together.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface ParseResult {
|
|
292
|
+
ok: boolean;
|
|
293
|
+
/** The validated object (present only when `ok`). */
|
|
294
|
+
value?: Record<string, unknown>;
|
|
295
|
+
error?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Unwrap (fences/prose), JSON-parse, and validate an agent's raw output against a
|
|
299
|
+
* contract in one step — the shared path for every engine backend. Every candidate is
|
|
300
|
+
* tried until one BOTH parses and validates: a candidate that parses but is not the
|
|
301
|
+
* answer object (trailing "option [2]", quoted code) must not quarantine a correct,
|
|
302
|
+
* already-billed leg as an invalid output. */
|
|
303
|
+
export function parseAndValidate(output: string, def: ContractDef): ParseResult {
|
|
304
|
+
let firstErrors: string[] | undefined;
|
|
305
|
+
for (const candidate of jsonCandidates(output)) {
|
|
306
|
+
let parsed: unknown;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(candidate);
|
|
309
|
+
} catch {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const v = validateAgainst(def, parsed);
|
|
313
|
+
if (v.ok && v.value) return { ok: true, value: v.value };
|
|
314
|
+
firstErrors ??= v.errors;
|
|
315
|
+
}
|
|
316
|
+
const errors = firstErrors ?? ["output was not valid JSON"];
|
|
317
|
+
return { ok: false, error: `contract ${def.name} failed: ${errors.join("; ")}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Deterministic, key-order-independent serialisation for hashing. */
|
|
321
|
+
function stableStringify(v: unknown): string {
|
|
322
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
|
323
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
324
|
+
const obj = v as Record<string, unknown>;
|
|
325
|
+
const keys = Object.keys(obj).sort();
|
|
326
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A stable sha256 over any JSON-ish value (key-order-independent) — the pinning hash. */
|
|
330
|
+
export function stableHash(value: unknown): string {
|
|
331
|
+
return createHash("sha256").update(stableStringify(value)).digest("hex");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Freeze a contract for a run: `name` + a stable sha256 over its definition. */
|
|
335
|
+
export function pinContract(def: ContractDef): PinnedContract {
|
|
336
|
+
return { name: def.name, hash: stableHash(def), def };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Every contract name a spec may reference: the built-in `default` plus the installed files. */
|
|
340
|
+
export function installedContractNames(defs: Record<string, ContractDef>): string[] {
|
|
341
|
+
return [...new Set(["default", ...Object.keys(defs)])];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Validate a structured value against a contract. Unknown extra fields are kept. */
|
|
345
|
+
export function validateAgainst(def: ContractDef, value: unknown): ValidationResult {
|
|
346
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
347
|
+
return { ok: false, errors: ["expected a JSON object"] };
|
|
348
|
+
}
|
|
349
|
+
const obj = value as Record<string, unknown>;
|
|
350
|
+
const errors: string[] = [];
|
|
351
|
+
|
|
352
|
+
for (const [name, spec] of Object.entries(def.fields)) {
|
|
353
|
+
const fv = obj[name];
|
|
354
|
+
const present = fv !== undefined && fv !== null;
|
|
355
|
+
if (!present) {
|
|
356
|
+
if (spec.required) errors.push(`missing required field: ${name}`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
switch (spec.type) {
|
|
360
|
+
case "string":
|
|
361
|
+
if (typeof fv !== "string") {
|
|
362
|
+
errors.push(`field ${name} must be a string`);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (spec.minLength !== undefined && fv.length < spec.minLength) {
|
|
366
|
+
errors.push(`field ${name} must be at least ${spec.minLength} characters`);
|
|
367
|
+
}
|
|
368
|
+
break;
|
|
369
|
+
case "number":
|
|
370
|
+
if (typeof fv !== "number" || Number.isNaN(fv)) {
|
|
371
|
+
errors.push(`field ${name} must be a number`);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (spec.min !== undefined && fv < spec.min) errors.push(`field ${name} must be >= ${spec.min}`);
|
|
375
|
+
if (spec.max !== undefined && fv > spec.max) errors.push(`field ${name} must be <= ${spec.max}`);
|
|
376
|
+
break;
|
|
377
|
+
case "boolean":
|
|
378
|
+
if (typeof fv !== "boolean") errors.push(`field ${name} must be a boolean`);
|
|
379
|
+
break;
|
|
380
|
+
case "enum": {
|
|
381
|
+
const values = spec.values ?? [];
|
|
382
|
+
if (typeof fv !== "string" || !values.includes(fv)) {
|
|
383
|
+
errors.push(`field ${name} must be one of: ${values.join(", ")}`);
|
|
384
|
+
}
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return errors.length === 0 ? { ok: true, errors, value: obj } : { ok: false, errors };
|
|
391
|
+
}
|