pi-simocracy 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/simocracy.ts CHANGED
@@ -10,6 +10,7 @@ const DEFAULT_INDEXER_URL = "https://simocracy-indexer-production.up.railway.app
10
10
  const COLLECTION_SIM = "org.simocracy.sim";
11
11
  const COLLECTION_AGENTS = "org.simocracy.agents";
12
12
  const COLLECTION_STYLE = "org.simocracy.style";
13
+ const COLLECTION_INTERVIEW_TEMPLATE = "org.simocracy.interviewTemplate";
13
14
 
14
15
  export interface SpriteSettings {
15
16
  selectedOptions: Record<string, string>;
@@ -253,6 +254,37 @@ export async function listRecordsFromPds<T>(did: string, collection: string): Pr
253
254
  return out;
254
255
  }
255
256
 
257
+ /**
258
+ * List every `org.simocracy.sim` record owned by `did`, mapped onto the
259
+ * same `SimMatch` shape that `searchSimsByName` produces so the rest of
260
+ * the extension's load/hydrate pipeline accepts these without a second
261
+ * code path. Sorted by `createdAt` descending (most recently created
262
+ * first), since that's how simocracy.org's My Sims carousel surfaces
263
+ * them and it's the most useful ordering when the user types `/sim my 1`.
264
+ */
265
+ export async function fetchSimsForDid(did: string): Promise<SimMatch[]> {
266
+ const records = await listRecordsFromPds<SimRecord>(did, COLLECTION_SIM);
267
+ return records
268
+ .filter((r) => r.value && typeof r.value.name === "string")
269
+ .map((r) => {
270
+ const rkey = r.uri.split("/").pop() ?? "";
271
+ return {
272
+ uri: r.uri,
273
+ cid: r.cid,
274
+ did,
275
+ rkey,
276
+ sim: r.value,
277
+ } satisfies SimMatch;
278
+ })
279
+ .sort((a, b) => {
280
+ // Most recent first; fall back to rkey (TIDs are roughly monotonic).
281
+ const ta = a.sim.createdAt || "";
282
+ const tb = b.sim.createdAt || "";
283
+ if (ta && tb) return tb.localeCompare(ta);
284
+ return b.rkey.localeCompare(a.rkey);
285
+ });
286
+ }
287
+
256
288
  /** Find the agents record for a sim by scanning the owner's PDS (sim-1:1-agents). */
257
289
  export async function fetchAgentsForSim(simUri: string): Promise<AgentsRecord | null> {
258
290
  const { did } = parseAtUri(simUri);
@@ -275,6 +307,95 @@ export async function fetchStyleForSim(simUri: string): Promise<StyleRecord | nu
275
307
  }
276
308
  }
277
309
 
310
+ // ---------------------------------------------------------------------------
311
+ // Interview templates (org.simocracy.interviewTemplate)
312
+ // ---------------------------------------------------------------------------
313
+
314
+ export interface InterviewQuestionRecord {
315
+ id: string;
316
+ type: "open" | "text" | "yesNo";
317
+ prompt: string;
318
+ required?: boolean;
319
+ }
320
+
321
+ export interface InterviewTemplateValue {
322
+ $type: "org.simocracy.interviewTemplate";
323
+ name: string;
324
+ description?: string;
325
+ questions: InterviewQuestionRecord[];
326
+ createdAt: string;
327
+ }
328
+
329
+ export interface LoadedInterviewTemplate {
330
+ uri: string;
331
+ cid: string;
332
+ did: string;
333
+ rkey: string;
334
+ template: InterviewTemplateValue;
335
+ }
336
+
337
+ /**
338
+ * List interview templates from the Simocracy indexer. Mirrors
339
+ * `fetchInterviewTemplates` in simocracy-v2's `lib/indexer.ts` (sans
340
+ * the PDS fallback against the facilitator — pi-simocracy doesn't
341
+ * know the facilitator DID, and an empty list is a soft failure
342
+ * handled by the caller via the built-in fallback template).
343
+ */
344
+ export async function searchInterviewTemplates(
345
+ limit = 100,
346
+ opts: { indexerUrl?: string } = {},
347
+ ): Promise<LoadedInterviewTemplate[]> {
348
+ const indexerUrl = opts.indexerUrl ?? DEFAULT_INDEXER_URL;
349
+ const results: LoadedInterviewTemplate[] = [];
350
+ let cursor: string | null = null;
351
+ for (let page = 0; page < 10 && results.length < limit; page++) {
352
+ const { nodes, hasNextPage, endCursor } = await fetchRecords(
353
+ COLLECTION_INTERVIEW_TEMPLATE,
354
+ Math.min(100, limit - results.length),
355
+ cursor,
356
+ indexerUrl,
357
+ );
358
+ for (const node of nodes) {
359
+ const value = node.value as unknown as InterviewTemplateValue;
360
+ if (!value?.name || !Array.isArray(value.questions)) continue;
361
+ results.push({
362
+ uri: node.uri,
363
+ cid: node.cid,
364
+ did: node.did,
365
+ rkey: node.rkey,
366
+ template: value,
367
+ });
368
+ }
369
+ if (!hasNextPage || !endCursor) break;
370
+ cursor = endCursor;
371
+ }
372
+ return results;
373
+ }
374
+
375
+ /**
376
+ * Fetch a single interview template directly from the owner's PDS by
377
+ * AT-URI. Returns null on any failure — callers fall through to the
378
+ * built-in template.
379
+ */
380
+ export async function fetchInterviewTemplateByUri(
381
+ templateUri: string,
382
+ ): Promise<LoadedInterviewTemplate | null> {
383
+ try {
384
+ const { did, collection, rkey } = parseAtUri(templateUri);
385
+ if (collection !== COLLECTION_INTERVIEW_TEMPLATE) return null;
386
+ const value = await getRecordFromPds<InterviewTemplateValue>(did, collection, rkey);
387
+ return {
388
+ uri: templateUri,
389
+ cid: "",
390
+ did,
391
+ rkey,
392
+ template: value,
393
+ };
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+
278
399
  /** Resolve handle of a DID via Bluesky AppView (best-effort). */
279
400
  export async function resolveHandle(did: string): Promise<string | null> {
280
401
  try {
@@ -0,0 +1,259 @@
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
+ }
@@ -0,0 +1,271 @@
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
+ }