pi-simocracy 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,259 +0,0 @@
1
- /**
2
- * `/sim train alignment` — score the sim against the user's hidden
3
- * baseline votes. Mirrors `/api/training/alignment-test` from
4
- * simocracy-v2.
5
- *
6
- * Calls the alignment prompt once per proposal with concurrency 4,
7
- * then prints a per-proposal table + overall match percentage and
8
- * weak-area summary.
9
- */
10
-
11
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
12
-
13
- import type { LoadedSim } from "../persona.ts";
14
- import { openRouterComplete, TRAINING_CHAT_MODEL } from "../openrouter.ts";
15
- import {
16
- loadTrainingLabState,
17
- saveTrainingLabState,
18
- } from "./storage.ts";
19
- import { clampConstitution, wrapAsData } from "./prompt-helpers.ts";
20
- import { TRAINING_ALIGNMENT_TEST_SYSTEM_PROMPT } from "./prompts.ts";
21
- import { bar } from "./profile.ts";
22
- import type {
23
- AlignmentResult,
24
- BaselineProposal,
25
- TrainingProfile,
26
- Vote,
27
- } from "./types.ts";
28
-
29
- const MIN_VOTES = 5;
30
- const CONCURRENCY = 4;
31
-
32
- export async function runAlignment(
33
- ctx: ExtensionCommandContext,
34
- loadedSim: LoadedSim,
35
- ): Promise<AlignmentResult | null> {
36
- let state = loadTrainingLabState(loadedSim.rkey);
37
- if (!state.profile) {
38
- ctx.ui.notify("Need a profile first — run `/sim train profile`.", "warning");
39
- return null;
40
- }
41
- const proposals = state.questionSet?.proposals ?? [];
42
- const cast = state.baselineVotes.filter(
43
- (v) => !(v.vote === "abstain" && Math.abs(v.importance - 0.5) < 0.01 && !v.reasoning),
44
- );
45
- if (cast.length < MIN_VOTES) {
46
- ctx.ui.notify(
47
- `Need at least ${MIN_VOTES} cast baseline votes to run alignment (you have ${cast.length}).`,
48
- "warning",
49
- );
50
- return null;
51
- }
52
-
53
- const aligned = proposals
54
- .map((proposal) => {
55
- const userVote = state.baselineVotes.find((v) => v.proposalId === proposal.id);
56
- if (!userVote) return null;
57
- if (
58
- userVote.vote === "abstain" &&
59
- Math.abs(userVote.importance - 0.5) < 0.01 &&
60
- !userVote.reasoning
61
- ) {
62
- return null;
63
- }
64
- return { proposal, userVote: userVote.vote };
65
- })
66
- .filter((entry): entry is { proposal: BaselineProposal; userVote: Vote } => entry !== null);
67
-
68
- ctx.ui.notify(
69
- `Running alignment test on ${aligned.length} proposals (concurrency ${CONCURRENCY})…`,
70
- "info",
71
- );
72
-
73
- let alignment: AlignmentResult;
74
- try {
75
- alignment = await scoreAlignment(loadedSim, state.profile, aligned);
76
- } catch (err) {
77
- ctx.ui.notify(`Alignment failed: ${(err as Error).message}`, "error");
78
- return null;
79
- }
80
-
81
- state = { ...state, alignment };
82
- saveTrainingLabState(loadedSim.rkey, state);
83
- printAlignment(alignment, aligned);
84
- return alignment;
85
- }
86
-
87
- export async function scoreAlignment(
88
- loadedSim: LoadedSim,
89
- profile: TrainingProfile,
90
- aligned: Array<{ proposal: BaselineProposal; userVote: Vote }>,
91
- ): Promise<AlignmentResult> {
92
- const simVotes = await mapWithConcurrency(aligned, CONCURRENCY, async (entry) =>
93
- askSimVote(loadedSim, profile, entry.proposal),
94
- );
95
-
96
- const results = aligned.map((entry, index) => {
97
- const sv = simVotes[index];
98
- return {
99
- proposalId: entry.proposal.id,
100
- userVote: entry.userVote,
101
- simVote: sv?.simVote ?? "abstain",
102
- matched: entry.userVote === sv?.simVote,
103
- confidence: sv?.confidence ?? 0,
104
- explanation: sv?.explanation ?? "No explanation returned.",
105
- };
106
- });
107
-
108
- const matchedCount = results.filter((r) => r.matched).length;
109
- const weakAreas = Array.from(
110
- new Set(
111
- results
112
- .filter((r) => !r.matched)
113
- .map((r) => aligned.find((a) => a.proposal.id === r.proposalId)?.proposal.topic)
114
- .filter((topic): topic is string => typeof topic === "string"),
115
- ),
116
- ).slice(0, 4);
117
-
118
- return {
119
- matchedCount,
120
- totalCount: results.length,
121
- results,
122
- weakAreas,
123
- };
124
- }
125
-
126
- interface SimVote {
127
- simVote: Vote;
128
- confidence: number;
129
- explanation: string;
130
- }
131
-
132
- async function askSimVote(
133
- loadedSim: LoadedSim,
134
- profile: TrainingProfile,
135
- proposal: BaselineProposal,
136
- ): Promise<SimVote> {
137
- const userPrompt = [
138
- `simName: ${loadedSim.name}`,
139
- wrapAsData("existingConstitution", clampConstitution(loadedSim.description)),
140
- wrapAsData("trainingProfile", JSON.stringify(profile, null, 2)),
141
- wrapAsData(
142
- "proposal",
143
- JSON.stringify(
144
- { id: proposal.id, title: proposal.title, summary: proposal.summary, topic: proposal.topic },
145
- null,
146
- 2,
147
- ),
148
- ),
149
- "Output shape: { vote: 'yes' | 'no' | 'abstain', confidence: 0-1, explanation: <280 chars }",
150
- ].join("\n\n");
151
-
152
- const content = await openRouterComplete(
153
- [
154
- { role: "system", content: TRAINING_ALIGNMENT_TEST_SYSTEM_PROMPT },
155
- { role: "user", content: userPrompt },
156
- ],
157
- { model: TRAINING_CHAT_MODEL, maxTokens: 350, temperature: 0.2 },
158
- );
159
-
160
- const parsed = parseJsonObject(content);
161
- const simVote = normalizeVote(parsed?.vote);
162
- const confidence = toUnit(parsed?.confidence);
163
- const explanation =
164
- typeof parsed?.explanation === "string" ? parsed.explanation.trim().slice(0, 280) : "";
165
- if (!simVote || confidence === null || !explanation) {
166
- throw new Error("Could not parse alignment vote from model output");
167
- }
168
- return { simVote, confidence, explanation };
169
- }
170
-
171
- async function mapWithConcurrency<T, U>(
172
- items: T[],
173
- limit: number,
174
- mapper: (item: T, index: number) => Promise<U>,
175
- ): Promise<U[]> {
176
- const results: U[] = [];
177
- let nextIndex = 0;
178
- async function worker() {
179
- while (nextIndex < items.length) {
180
- const i = nextIndex++;
181
- const item = items[i];
182
- if (item !== undefined) {
183
- results[i] = await mapper(item, i);
184
- }
185
- }
186
- }
187
- const workerCount = Math.min(limit, items.length);
188
- await Promise.all(Array.from({ length: workerCount }, () => worker()));
189
- return results;
190
- }
191
-
192
- // -----------------------------------------------------------------
193
- // JSON parsing — same shape as profile.ts
194
- // -----------------------------------------------------------------
195
-
196
- function parseJsonObject(content: string): Record<string, unknown> | null {
197
- const trimmed = content.trim();
198
- try {
199
- const parsed: unknown = JSON.parse(trimmed);
200
- return isRecord(parsed) ? parsed : null;
201
- } catch {
202
- const start = trimmed.indexOf("{");
203
- const end = trimmed.lastIndexOf("}");
204
- if (start === -1 || end === -1 || end <= start) return null;
205
- try {
206
- const parsed: unknown = JSON.parse(trimmed.slice(start, end + 1));
207
- return isRecord(parsed) ? parsed : null;
208
- } catch {
209
- return null;
210
- }
211
- }
212
- }
213
-
214
- function isRecord(value: unknown): value is Record<string, unknown> {
215
- return typeof value === "object" && value !== null && !Array.isArray(value);
216
- }
217
-
218
- function toUnit(value: unknown): number | null {
219
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
220
- return Math.min(1, Math.max(0, value));
221
- }
222
-
223
- function normalizeVote(value: unknown): Vote | null {
224
- if (value === "yes" || value === "no" || value === "abstain") return value;
225
- if (typeof value !== "string") return null;
226
- const normalized = value.toLowerCase();
227
- return normalized === "yes" || normalized === "no" || normalized === "abstain"
228
- ? (normalized as Vote)
229
- : null;
230
- }
231
-
232
- // -----------------------------------------------------------------
233
- // Rendering
234
- // -----------------------------------------------------------------
235
-
236
- export function printAlignment(
237
- alignment: AlignmentResult,
238
- aligned: Array<{ proposal: BaselineProposal; userVote: Vote }>,
239
- ): void {
240
- const lines: string[] = [""];
241
- for (const r of alignment.results) {
242
- const prop = aligned.find((a) => a.proposal.id === r.proposalId)?.proposal;
243
- const title = prop?.title ?? r.proposalId;
244
- const symbol = r.matched ? "✓" : "✗";
245
- const u = r.userVote.padEnd(7, " ");
246
- const s = r.simVote.padEnd(7, " ");
247
- lines.push(`${symbol} user:${u} ↔ sim:${s} ${bar(r.confidence)} ${title}`);
248
- }
249
- lines.push("");
250
- const pct =
251
- alignment.totalCount > 0
252
- ? Math.round((alignment.matchedCount / alignment.totalCount) * 100)
253
- : 0;
254
- lines.push(`Match: ${alignment.matchedCount}/${alignment.totalCount} (${pct}%)`);
255
- if (alignment.weakAreas.length) {
256
- lines.push(`Weak areas: ${alignment.weakAreas.join(", ")}`);
257
- }
258
- console.log(lines.join("\n"));
259
- }
@@ -1,271 +0,0 @@
1
- /**
2
- * `/sim train apply` — merge the distilled profile into the sim's
3
- * existing constitution + short description. PR 2 prints the result
4
- * and copies it to the system clipboard so the user can paste it
5
- * into simocracy.org. PR 3 will write to the user's PDS when
6
- * `--apply` is passed and the user is signed in.
7
- *
8
- * Mirrors `/api/training/merge-constitution` from simocracy-v2.
9
- */
10
-
11
- import { spawn } from "node:child_process";
12
- import { platform } from "node:os";
13
-
14
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
15
-
16
- import type { LoadedSim } from "../persona.ts";
17
- import { openRouterComplete, TRAINING_REASONING_MODEL } from "../openrouter.ts";
18
- import { readAuth } from "../auth/storage.ts";
19
- import { resolveHandle } from "../simocracy.ts";
20
- import {
21
- createAgents,
22
- findRkeyForSim,
23
- getAuthenticatedAgent,
24
- NotSignedInError,
25
- updateAgents,
26
- } from "../writes.ts";
27
- import {
28
- loadTrainingLabState,
29
- saveTrainingLabState,
30
- } from "./storage.ts";
31
- import { clampConstitution, wrapAsData } from "./prompt-helpers.ts";
32
- import { TRAINING_MERGE_CONSTITUTION_SYSTEM_PROMPT } from "./prompts.ts";
33
- import type { TrainingProfile } from "./types.ts";
34
-
35
- export interface MergeOutput {
36
- shortDescription: string;
37
- description: string;
38
- }
39
-
40
- export async function runApply(
41
- ctx: ExtensionCommandContext,
42
- loadedSim: LoadedSim,
43
- opts: { apply?: boolean } = {},
44
- ): Promise<MergeOutput | null> {
45
- const state = loadTrainingLabState(loadedSim.rkey);
46
- if (!state.profile) {
47
- ctx.ui.notify("Need a profile first — run `/sim train profile`.", "warning");
48
- return null;
49
- }
50
-
51
- ctx.ui.notify("Merging profile into constitution (this may take a few seconds)…", "info");
52
- let merged: MergeOutput;
53
- try {
54
- merged = await mergeConstitution(loadedSim, state.profile);
55
- } catch (err) {
56
- ctx.ui.notify(`Merge failed: ${(err as Error).message}`, "error");
57
- return null;
58
- }
59
-
60
- // Re-save state so the updatedAt bumps for /sim train status; the
61
- // canonical record still lives on the user's PDS.
62
- saveTrainingLabState(loadedSim.rkey, state);
63
-
64
- console.log("");
65
- console.log(`Short description:`);
66
- console.log(merged.shortDescription);
67
- console.log("");
68
- console.log(`Constitution (markdown):`);
69
- console.log(merged.description);
70
- console.log("");
71
-
72
- if (opts.apply) {
73
- const written = await writeAgentsToPds(ctx, loadedSim, merged);
74
- if (written) return merged;
75
- return null;
76
- }
77
-
78
- const clipboardPayload = formatClipboard(merged);
79
- const copied = await copyToClipboard(clipboardPayload);
80
- if (copied) {
81
- ctx.ui.notify(
82
- `Copied to clipboard. Paste into the constitution editor at https://simocracy.org/sims/${loadedSim.did}/${loadedSim.rkey}, or re-run with --apply once you've signed in via /sim login.`,
83
- "info",
84
- );
85
- } else {
86
- ctx.ui.notify(
87
- "Could not copy to clipboard — copy the printed output manually, or sign in via /sim login and re-run with --apply.",
88
- "warning",
89
- );
90
- }
91
- return merged;
92
- }
93
-
94
- /**
95
- * Write the merged constitution to the user's PDS via OAuth. Enforces
96
- * the owner check (loaded sim's DID must match the signed-in DID).
97
- * Returns true on success.
98
- */
99
- async function writeAgentsToPds(
100
- ctx: ExtensionCommandContext,
101
- loadedSim: LoadedSim,
102
- merged: MergeOutput,
103
- ): Promise<boolean> {
104
- const auth = readAuth();
105
- if (!auth) {
106
- ctx.ui.notify("Not signed into ATProto. Run `/sim login <handle>` first (e.g. `/sim login alice.bsky.social`).", "error");
107
- return false;
108
- }
109
- if (auth.did !== loadedSim.did) {
110
- const ownerHandle =
111
- loadedSim.handle ?? (await resolveHandle(loadedSim.did).catch(() => null));
112
- ctx.ui.notify(
113
- `You can only apply to sims you own. Loaded sim is owned by ${
114
- ownerHandle ? `@${ownerHandle}` : loadedSim.did
115
- } — your signed-in DID is ${auth.did}.`,
116
- "error",
117
- );
118
- return false;
119
- }
120
-
121
- let agent;
122
- try {
123
- ({ agent } = await getAuthenticatedAgent());
124
- } catch (err) {
125
- if (err instanceof NotSignedInError) {
126
- ctx.ui.notify(err.message, "error");
127
- } else {
128
- ctx.ui.notify(`Auth failed: ${(err as Error).message}`, "error");
129
- }
130
- return false;
131
- }
132
-
133
- const existingRkey = await findRkeyForSim(
134
- agent,
135
- auth.did,
136
- "org.simocracy.agents",
137
- loadedSim.uri,
138
- ).catch(() => null);
139
-
140
- ctx.ui.notify(
141
- existingRkey
142
- ? `Updating org.simocracy.agents (${existingRkey})…`
143
- : "Creating org.simocracy.agents…",
144
- "info",
145
- );
146
- try {
147
- if (existingRkey) {
148
- await updateAgents({
149
- agent,
150
- did: auth.did,
151
- rkey: existingRkey,
152
- simUri: loadedSim.uri,
153
- simCid: "", // CID required by lexicon validators is permissive — empty is acceptable for a fresh write here.
154
- shortDescription: merged.shortDescription,
155
- description: merged.description,
156
- });
157
- } else {
158
- await createAgents({
159
- agent,
160
- did: auth.did,
161
- simUri: loadedSim.uri,
162
- simCid: "",
163
- shortDescription: merged.shortDescription,
164
- description: merged.description,
165
- });
166
- }
167
- } catch (err) {
168
- ctx.ui.notify(`PDS write failed: ${(err as Error).message}`, "error");
169
- return false;
170
- }
171
-
172
- ctx.ui.notify(
173
- `Wrote constitution to ${auth.handle ? `@${auth.handle}` : auth.did}'s PDS. Refresh https://simocracy.org/sims/${loadedSim.did}/${loadedSim.rkey} to see it.`,
174
- "info",
175
- );
176
- return true;
177
- }
178
-
179
- export async function mergeConstitution(
180
- loadedSim: LoadedSim,
181
- profile: TrainingProfile,
182
- ): Promise<MergeOutput> {
183
- const userPrompt = [
184
- `simName: ${loadedSim.name}`,
185
- wrapAsData(
186
- "existingConstitution",
187
- loadedSim.description?.trim()
188
- ? clampConstitution(loadedSim.description)
189
- : "(empty — write one from scratch using the profile)",
190
- ),
191
- wrapAsData(
192
- "existingShortDescription",
193
- loadedSim.shortDescription?.trim() || "(none)",
194
- ),
195
- wrapAsData("speakingStyle", loadedSim.style?.trim() || "(none)"),
196
- wrapAsData("trainingProfile", JSON.stringify(profile, null, 2)),
197
- ].join("\n\n");
198
-
199
- const content = await openRouterComplete(
200
- [
201
- { role: "system", content: TRAINING_MERGE_CONSTITUTION_SYSTEM_PROMPT },
202
- { role: "user", content: userPrompt },
203
- ],
204
- { model: TRAINING_REASONING_MODEL, maxTokens: 4000, temperature: 0.4 },
205
- );
206
-
207
- return parseMergeOutput(content, profile);
208
- }
209
-
210
- /**
211
- * Mirrors `parseMergeOutput` in
212
- * `simocracy-v2/app/api/training/merge-constitution/route.ts`. Tolerates
213
- * a missing separator (uses profile.summary as the short description).
214
- */
215
- export function parseMergeOutput(
216
- content: string,
217
- profile: TrainingProfile,
218
- ): MergeOutput {
219
- const lines = content.split("\n");
220
- const separatorIdx = lines.findIndex((line) => line.trim() === "---");
221
-
222
- let shortDescription: string;
223
- let description: string;
224
- if (separatorIdx > 0) {
225
- shortDescription = lines.slice(0, separatorIdx).join("\n").trim();
226
- description = lines.slice(separatorIdx + 1).join("\n").trim();
227
- } else {
228
- description = content.trim();
229
- shortDescription = profile.summary?.trim() || "";
230
- }
231
-
232
- if (shortDescription.length > 300) {
233
- shortDescription = `${shortDescription.slice(0, 297).trimEnd()}…`;
234
- }
235
- return { shortDescription, description };
236
- }
237
-
238
- function formatClipboard(merged: MergeOutput): string {
239
- return [
240
- "## Short description",
241
- merged.shortDescription,
242
- "",
243
- "## Constitution",
244
- merged.description,
245
- ].join("\n");
246
- }
247
-
248
- /** Best-effort clipboard copy. No new npm deps — shells out per-OS. */
249
- async function copyToClipboard(text: string): Promise<boolean> {
250
- const cmd = clipboardCommand();
251
- if (!cmd) return false;
252
- return new Promise((resolve) => {
253
- try {
254
- const child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "ignore"] });
255
- child.on("error", () => resolve(false));
256
- child.on("close", (code) => resolve(code === 0));
257
- child.stdin?.end(text);
258
- } catch {
259
- resolve(false);
260
- }
261
- });
262
- }
263
-
264
- function clipboardCommand(): { command: string; args: string[] } | null {
265
- const os = platform();
266
- if (os === "darwin") return { command: "pbcopy", args: [] };
267
- if (os === "win32") return { command: "clip", args: [] };
268
- // Linux — try xclip, then xsel, then wl-copy. We only return one;
269
- // callers fall back to "couldn't copy" if it fails to run.
270
- return { command: "xclip", args: ["-selection", "clipboard"] };
271
- }
@@ -1,159 +0,0 @@
1
- /**
2
- * `/sim train baseline` — vote yes/no/abstain on the loaded sim's
3
- * baseline proposals, with importance + optional reasoning.
4
- *
5
- * Web parity: `simocracy-v2/components/sim/training-lab/baseline-tab.tsx`.
6
- */
7
-
8
- import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
9
-
10
- import type { LoadedSim } from "../persona.ts";
11
- import {
12
- loadTrainingLabState,
13
- saveTrainingLabState,
14
- } from "./storage.ts";
15
- import {
16
- pickInterviewTemplate,
17
- questionSetFromTemplate,
18
- } from "./question-set.ts";
19
- import type { BaselineProposal, BaselineVote, Vote } from "./types.ts";
20
-
21
- const IMPORTANCE_OPTIONS: Array<{ label: string; value: number }> = [
22
- { label: "Negligible (0.1)", value: 0.1 },
23
- { label: "Low (0.3)", value: 0.3 },
24
- { label: "Medium (0.5)", value: 0.5 },
25
- { label: "High (0.7)", value: 0.7 },
26
- { label: "Critical (0.9)", value: 0.9 },
27
- ];
28
-
29
- const VOTE_OPTIONS: Array<{ label: string; value: Vote | "skip" }> = [
30
- { label: "Yes — agree", value: "yes" },
31
- { label: "No — disagree", value: "no" },
32
- { label: "Abstain — uncertain", value: "abstain" },
33
- { label: "Skip — don't vote on this one", value: "skip" },
34
- ];
35
-
36
- export async function runBaseline(
37
- ctx: ExtensionCommandContext,
38
- loadedSim: LoadedSim,
39
- ): Promise<void> {
40
- let state = loadTrainingLabState(loadedSim.rkey);
41
-
42
- // Bootstrap or refresh the question set if missing.
43
- if (!state.questionSet || state.questionSet.proposals.length === 0) {
44
- const picked = await pickInterviewTemplate(ctx);
45
- if (!picked) {
46
- ctx.ui.notify("Cancelled.", "info");
47
- return;
48
- }
49
- state = {
50
- ...state,
51
- questionSet: questionSetFromTemplate(picked.template, picked.uri),
52
- profile: null,
53
- alignment: null,
54
- };
55
- saveTrainingLabState(loadedSim.rkey, state);
56
- }
57
-
58
- const proposals = state.questionSet?.proposals ?? [];
59
- if (proposals.length === 0) {
60
- ctx.ui.notify("Picked template has no yes/no questions — nothing to vote on.", "warning");
61
- return;
62
- }
63
-
64
- ctx.ui.notify(
65
- `Voting on ${proposals.length} baseline proposals for ${loadedSim.name}. Cancel any prompt to stop early.`,
66
- "info",
67
- );
68
-
69
- for (let i = 0; i < proposals.length; i++) {
70
- const proposal = proposals[i];
71
- const existing = state.baselineVotes.find((v) => v.proposalId === proposal.id);
72
- const answer = await askVote(ctx, proposal, i, proposals.length, existing);
73
- if (answer === null) {
74
- ctx.ui.notify("Stopped — votes saved up to this point.", "info");
75
- break;
76
- }
77
- state = {
78
- ...state,
79
- baselineVotes: recordBaselineVote(state.baselineVotes, proposal.id, answer),
80
- // Re-distilling and alignment depend on the votes — clear when votes change.
81
- profile: null,
82
- alignment: null,
83
- };
84
- saveTrainingLabState(loadedSim.rkey, state);
85
- }
86
-
87
- const cast = state.baselineVotes.filter(
88
- (v) => !(v.vote === "abstain" && Math.abs(v.importance - 0.5) < 0.01 && !v.reasoning),
89
- ).length;
90
- ctx.ui.notify(
91
- `Saved ${cast} baseline votes. Run \`/sim train chat\` next, then \`/sim train profile\`.`,
92
- "info",
93
- );
94
- }
95
-
96
- interface AnsweredVote {
97
- vote: Vote;
98
- importance: number;
99
- reasoning: string;
100
- }
101
-
102
- async function askVote(
103
- ctx: ExtensionCommandContext,
104
- proposal: BaselineProposal,
105
- index: number,
106
- total: number,
107
- existing: BaselineVote | undefined,
108
- ): Promise<AnsweredVote | null> {
109
- const header = existing
110
- ? `[${index + 1}/${total}] ${proposal.title} (current: ${existing.vote.toUpperCase()})`
111
- : `[${index + 1}/${total}] ${proposal.title}`;
112
-
113
- const voteLabel = await ctx.ui.select(header, VOTE_OPTIONS.map((o) => o.label));
114
- if (!voteLabel) return null;
115
- const voteEntry = VOTE_OPTIONS.find((o) => o.label === voteLabel);
116
- if (!voteEntry) return null;
117
-
118
- // Skip leaves the abstain default at importance 0.5 with no reasoning,
119
- // which the prompt-helpers' renderer treats as untouched.
120
- if (voteEntry.value === "skip") {
121
- return { vote: "abstain", importance: 0.5, reasoning: "" };
122
- }
123
-
124
- const importanceLabel = await ctx.ui.select(
125
- "How important is this to you?",
126
- IMPORTANCE_OPTIONS.map((o) => o.label),
127
- );
128
- if (!importanceLabel) return null;
129
- const importance =
130
- IMPORTANCE_OPTIONS.find((o) => o.label === importanceLabel)?.value ?? 0.5;
131
-
132
- const reasoning = await ctx.ui.input(
133
- "Optional reasoning (Enter to skip)",
134
- `Why did you vote ${voteEntry.value}?`,
135
- );
136
-
137
- return {
138
- vote: voteEntry.value,
139
- importance,
140
- reasoning: (reasoning ?? "").trim(),
141
- };
142
- }
143
-
144
- function recordBaselineVote(
145
- votes: BaselineVote[],
146
- proposalId: string,
147
- next: AnsweredVote,
148
- ): BaselineVote[] {
149
- const without = votes.filter((v) => v.proposalId !== proposalId);
150
- return [
151
- ...without,
152
- {
153
- proposalId,
154
- vote: next.vote,
155
- importance: next.importance,
156
- reasoning: next.reasoning,
157
- },
158
- ];
159
- }