pi-simocracy 0.1.0 → 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/package.json +3 -1
- package/src/auth/callback-server.ts +93 -0
- package/src/auth/commands.ts +159 -0
- package/src/auth/oauth.ts +55 -0
- package/src/auth/pages.ts +100 -0
- package/src/auth/storage.ts +121 -0
- package/src/index.ts +663 -66
- package/src/interview.ts +516 -0
- package/src/openrouter.ts +16 -0
- package/src/persona.ts +60 -0
- package/src/png-to-ansi.ts +100 -0
- package/src/simocracy.ts +121 -0
- package/src/training/alignment.ts +259 -0
- package/src/training/apply.ts +271 -0
- package/src/training/baseline.ts +159 -0
- package/src/training/chat.ts +131 -0
- package/src/training/feedback.ts +81 -0
- package/src/training/index.ts +142 -0
- package/src/training/profile.ts +229 -0
- package/src/training/prompt-helpers.ts +70 -0
- package/src/training/prompts.ts +131 -0
- package/src/training/question-set.ts +134 -0
- package/src/training/storage.ts +81 -0
- package/src/training/types.ts +121 -0
- package/src/writes.ts +245 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/sim train profile` — distill baseline + transcript into a
|
|
3
|
+
* `TrainingProfile`. Mirrors `/api/training/extract-profile` from
|
|
4
|
+
* simocracy-v2.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import type { LoadedSim } from "../persona.ts";
|
|
10
|
+
import { openRouterComplete, TRAINING_CHAT_MODEL } from "../openrouter.ts";
|
|
11
|
+
import {
|
|
12
|
+
loadTrainingLabState,
|
|
13
|
+
saveTrainingLabState,
|
|
14
|
+
} from "./storage.ts";
|
|
15
|
+
import {
|
|
16
|
+
clampConstitution,
|
|
17
|
+
clampTranscript,
|
|
18
|
+
renderBaselineForPrompt,
|
|
19
|
+
wrapAsData,
|
|
20
|
+
} from "./prompt-helpers.ts";
|
|
21
|
+
import { TRAINING_EXTRACT_PROFILE_SYSTEM_PROMPT } from "./prompts.ts";
|
|
22
|
+
import type { IssuePriority, TrainingProfile } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
export async function runProfile(
|
|
25
|
+
ctx: ExtensionCommandContext,
|
|
26
|
+
loadedSim: LoadedSim,
|
|
27
|
+
): Promise<TrainingProfile | null> {
|
|
28
|
+
let state = loadTrainingLabState(loadedSim.rkey);
|
|
29
|
+
const proposals = state.questionSet?.proposals ?? [];
|
|
30
|
+
if (state.baselineVotes.length === 0 && state.interviewTurns.length === 0) {
|
|
31
|
+
ctx.ui.notify(
|
|
32
|
+
"Need at least some baseline votes or interview turns before distilling. Run `/sim train baseline` first.",
|
|
33
|
+
"warning",
|
|
34
|
+
);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ctx.ui.notify("Distilling profile…", "info");
|
|
39
|
+
let profile: TrainingProfile | null;
|
|
40
|
+
try {
|
|
41
|
+
profile = await deriveProfile(loadedSim, state, proposals);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
ctx.ui.notify(`OpenRouter error: ${(err as Error).message}`, "error");
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!profile) {
|
|
48
|
+
ctx.ui.notify("Could not parse profile from model response.", "error");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
state = { ...state, profile, alignment: null };
|
|
53
|
+
saveTrainingLabState(loadedSim.rkey, state);
|
|
54
|
+
printProfile(profile);
|
|
55
|
+
ctx.ui.notify(
|
|
56
|
+
"Profile saved. Run `/sim train alignment` next, or `/sim train apply` to merge it into the constitution.",
|
|
57
|
+
"info",
|
|
58
|
+
);
|
|
59
|
+
return profile;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function deriveProfile(
|
|
63
|
+
loadedSim: LoadedSim,
|
|
64
|
+
state: ReturnType<typeof loadTrainingLabState>,
|
|
65
|
+
proposals: { id: string; title: string; summary: string; topic: string }[],
|
|
66
|
+
): Promise<TrainingProfile | null> {
|
|
67
|
+
const constitution = clampConstitution(loadedSim.description);
|
|
68
|
+
const baseline = renderBaselineForPrompt(state.baselineVotes, proposals);
|
|
69
|
+
const transcript = clampTranscript(state.interviewTurns);
|
|
70
|
+
|
|
71
|
+
const userPrompt = [
|
|
72
|
+
`simName: ${loadedSim.name}`,
|
|
73
|
+
wrapAsData("existingConstitution", constitution),
|
|
74
|
+
wrapAsData("baselineQuestionnaire", baseline),
|
|
75
|
+
wrapAsData("transcript", JSON.stringify(transcript, null, 2)),
|
|
76
|
+
].join("\n\n");
|
|
77
|
+
|
|
78
|
+
const content = await openRouterComplete(
|
|
79
|
+
[
|
|
80
|
+
{ role: "system", content: TRAINING_EXTRACT_PROFILE_SYSTEM_PROMPT },
|
|
81
|
+
{ role: "user", content: userPrompt },
|
|
82
|
+
],
|
|
83
|
+
{ model: TRAINING_CHAT_MODEL, maxTokens: 1800, temperature: 0.3 },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return parseProfile(content);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -----------------------------------------------------------------
|
|
90
|
+
// Parsing — mirrors `normalizeProfile` in
|
|
91
|
+
// `simocracy-v2/app/api/training/extract-profile/route.ts`.
|
|
92
|
+
// -----------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function parseProfile(content: string): TrainingProfile | null {
|
|
95
|
+
const parsed = parseJsonObject(content);
|
|
96
|
+
if (!parsed) return null;
|
|
97
|
+
|
|
98
|
+
const coreValues = normalizeStringArray(parsed.coreValues, 7);
|
|
99
|
+
const issuePriorities = normalizeIssuePriorities(parsed.issuePriorities);
|
|
100
|
+
const redLines = normalizeStringArray(parsed.redLines, 6);
|
|
101
|
+
const acceptableTradeoffs = normalizeStringArray(parsed.acceptableTradeoffs, 6);
|
|
102
|
+
const uncertaintyAreas = normalizeStringArray(parsed.uncertaintyAreas, 6);
|
|
103
|
+
const representationRules = normalizeStringArray(parsed.representationRules, 5);
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
typeof parsed.summary !== "string" ||
|
|
107
|
+
coreValues.length < 3 ||
|
|
108
|
+
issuePriorities.length < 4
|
|
109
|
+
) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
summary: parsed.summary.trim(),
|
|
115
|
+
coreValues,
|
|
116
|
+
issuePriorities,
|
|
117
|
+
redLines,
|
|
118
|
+
acceptableTradeoffs,
|
|
119
|
+
uncertaintyAreas,
|
|
120
|
+
representationRules,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseJsonObject(content: string): Record<string, unknown> | null {
|
|
125
|
+
const trimmed = content.trim();
|
|
126
|
+
try {
|
|
127
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
128
|
+
return isRecord(parsed) ? parsed : null;
|
|
129
|
+
} catch {
|
|
130
|
+
const start = trimmed.indexOf("{");
|
|
131
|
+
const end = trimmed.lastIndexOf("}");
|
|
132
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
133
|
+
try {
|
|
134
|
+
const parsed: unknown = JSON.parse(trimmed.slice(start, end + 1));
|
|
135
|
+
return isRecord(parsed) ? parsed : null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeStringArray(value: unknown, max: number): string[] {
|
|
147
|
+
if (!Array.isArray(value)) return [];
|
|
148
|
+
return value
|
|
149
|
+
.filter((item): item is string => typeof item === "string")
|
|
150
|
+
.map((item) => item.trim())
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.slice(0, max);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeIssuePriorities(value: unknown): IssuePriority[] {
|
|
156
|
+
if (!Array.isArray(value)) return [];
|
|
157
|
+
return value
|
|
158
|
+
.map((item) => {
|
|
159
|
+
if (!isRecord(item)) return null;
|
|
160
|
+
if (typeof item.issue !== "string" || typeof item.stance !== "string") return null;
|
|
161
|
+
const importance = toUnit(item.importance);
|
|
162
|
+
const negotiability = toUnit(item.negotiability);
|
|
163
|
+
const confidence = toUnit(item.confidence);
|
|
164
|
+
if (importance === null || negotiability === null || confidence === null) return null;
|
|
165
|
+
return {
|
|
166
|
+
issue: item.issue.trim(),
|
|
167
|
+
stance: item.stance.trim(),
|
|
168
|
+
importance,
|
|
169
|
+
negotiability,
|
|
170
|
+
confidence,
|
|
171
|
+
};
|
|
172
|
+
})
|
|
173
|
+
.filter((item): item is IssuePriority => item !== null)
|
|
174
|
+
.slice(0, 10);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function toUnit(value: unknown): number | null {
|
|
178
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
179
|
+
return Math.min(1, Math.max(0, value));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -----------------------------------------------------------------
|
|
183
|
+
// Rendering
|
|
184
|
+
// -----------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const BAR_WIDTH = 10;
|
|
187
|
+
|
|
188
|
+
export function bar(value: number): string {
|
|
189
|
+
const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round(value * BAR_WIDTH)));
|
|
190
|
+
return `[${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}]`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function printProfile(profile: TrainingProfile): void {
|
|
194
|
+
const lines: string[] = [];
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push(`Summary: ${profile.summary}`);
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(`Core Values:`);
|
|
199
|
+
for (const v of profile.coreValues) lines.push(` - ${v}`);
|
|
200
|
+
lines.push("");
|
|
201
|
+
lines.push(`Issue Priorities:`);
|
|
202
|
+
for (const p of profile.issuePriorities) {
|
|
203
|
+
lines.push(
|
|
204
|
+
` ${bar(p.importance)} importance | ${bar(p.negotiability)} negotiability | ${bar(p.confidence)} confidence`,
|
|
205
|
+
);
|
|
206
|
+
lines.push(` ${p.issue} — ${p.stance}`);
|
|
207
|
+
lines.push("");
|
|
208
|
+
}
|
|
209
|
+
if (profile.redLines.length) {
|
|
210
|
+
lines.push(`Red Lines:`);
|
|
211
|
+
for (const r of profile.redLines) lines.push(` - ${r}`);
|
|
212
|
+
lines.push("");
|
|
213
|
+
}
|
|
214
|
+
if (profile.acceptableTradeoffs.length) {
|
|
215
|
+
lines.push(`Acceptable Tradeoffs:`);
|
|
216
|
+
for (const t of profile.acceptableTradeoffs) lines.push(` - ${t}`);
|
|
217
|
+
lines.push("");
|
|
218
|
+
}
|
|
219
|
+
if (profile.uncertaintyAreas.length) {
|
|
220
|
+
lines.push(`Uncertainty Areas:`);
|
|
221
|
+
for (const u of profile.uncertaintyAreas) lines.push(` - ${u}`);
|
|
222
|
+
lines.push("");
|
|
223
|
+
}
|
|
224
|
+
if (profile.representationRules.length) {
|
|
225
|
+
lines.push(`Representation Rules:`);
|
|
226
|
+
for (const r of profile.representationRules) lines.push(` - ${r}`);
|
|
227
|
+
}
|
|
228
|
+
console.log(lines.join("\n"));
|
|
229
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for training LLM prompts.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of `simocracy-v2/lib/training/prompt-helpers.ts` (subset that
|
|
5
|
+
* the CLI actually uses). Keep clamps + wrappers in sync with the web
|
|
6
|
+
* app so the same input produces the same prompt locally.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { BaselineProposal, BaselineVote, InterviewTurn } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export const CONSTITUTION_MAX_CHARS = 6000;
|
|
12
|
+
export const TRANSCRIPT_MAX_TURNS = 16;
|
|
13
|
+
|
|
14
|
+
export function clampConstitution(text: string | undefined): string {
|
|
15
|
+
if (!text) return "(No current constitution provided)";
|
|
16
|
+
const trimmed = text.trim();
|
|
17
|
+
if (trimmed.length <= CONSTITUTION_MAX_CHARS) return trimmed;
|
|
18
|
+
return `${trimmed.slice(0, CONSTITUTION_MAX_CHARS)}\n\n[…truncated for prompt size; original is ${trimmed.length} chars]`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function clampTranscript(turns: InterviewTurn[]): InterviewTurn[] {
|
|
22
|
+
if (turns.length <= TRANSCRIPT_MAX_TURNS) return turns;
|
|
23
|
+
return turns.slice(-TRANSCRIPT_MAX_TURNS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function wrapAsData(label: string, content: string): string {
|
|
27
|
+
return `<${label}>\n${content}\n</${label}>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderBaselineForPrompt(
|
|
31
|
+
baselineVotes: BaselineVote[],
|
|
32
|
+
baselineProposals: BaselineProposal[],
|
|
33
|
+
): string {
|
|
34
|
+
const enriched = baselineVotes
|
|
35
|
+
.map((vote) => {
|
|
36
|
+
const proposal = baselineProposals.find((item) => item.id === vote.proposalId);
|
|
37
|
+
const reasoning = (vote.reasoning ?? "").trim();
|
|
38
|
+
const isUntouched =
|
|
39
|
+
vote.vote === "abstain" && Math.abs(vote.importance - 0.5) < 0.01 && !reasoning;
|
|
40
|
+
if (isUntouched) return null;
|
|
41
|
+
return { vote, proposal, reasoning };
|
|
42
|
+
})
|
|
43
|
+
.filter(
|
|
44
|
+
(entry): entry is { vote: BaselineVote; proposal: BaselineProposal | undefined; reasoning: string } =>
|
|
45
|
+
entry !== null,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (enriched.length === 0) {
|
|
49
|
+
return "(The user has not voted on any baseline proposals yet.)";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ranked = enriched
|
|
53
|
+
.map((entry) => {
|
|
54
|
+
const cast = entry.vote.vote === "abstain" ? 0 : 1;
|
|
55
|
+
return { ...entry, score: cast * 10 + entry.vote.importance };
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => b.score - a.score);
|
|
58
|
+
|
|
59
|
+
return ranked
|
|
60
|
+
.map(({ vote, proposal, reasoning }, index) => {
|
|
61
|
+
const title = proposal?.title ?? vote.proposalId;
|
|
62
|
+
const topic = proposal?.topic ? ` (${proposal.topic})` : "";
|
|
63
|
+
const verdict = vote.vote.toUpperCase();
|
|
64
|
+
const importance = vote.importance.toFixed(2);
|
|
65
|
+
const summary = proposal?.summary ? ` — proposal: "${proposal.summary}"` : "";
|
|
66
|
+
const comment = reasoning ? `\n Comment from user: "${reasoning}"` : "";
|
|
67
|
+
return `${index + 1}. ${verdict} (importance ${importance}) on "${title}"${topic}${summary}${comment}`;
|
|
68
|
+
})
|
|
69
|
+
.join("\n\n");
|
|
70
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared LLM system prompts for Training Lab + Interview flows.
|
|
3
|
+
*
|
|
4
|
+
* **Mirror of `simocracy-v2/lib/training/prompts.ts`.** Keep contents
|
|
5
|
+
* byte-identical — drift means the CLI's output diverges from the
|
|
6
|
+
* web app's for the same input.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* System prompt for the adaptive-interview chat turn (mirrors
|
|
11
|
+
* `/api/training/next-question` on simocracy-v2). Parameterised
|
|
12
|
+
* because each turn includes the sim's current speaking-style block.
|
|
13
|
+
*/
|
|
14
|
+
export function buildNextQuestionSystemPrompt(simName: string, existingStyle?: string): string {
|
|
15
|
+
const styleBlock = existingStyle?.trim()
|
|
16
|
+
? `\n\n## Your Speaking Style\n${existingStyle.trim()}\n\nKeep this voice in every reply.`
|
|
17
|
+
: "";
|
|
18
|
+
|
|
19
|
+
return `You are ${simName} — a political AI representative the user is currently training. The user has answered a baseline questionnaire (yes/no votes on a set of proposals, with an importance slider and an optional comment per vote) and is now chatting with you so you can fill in the gaps.${styleBlock}
|
|
20
|
+
|
|
21
|
+
The baseline questionnaire is your primary source of signal. Before each reply you should mentally scan it for:
|
|
22
|
+
|
|
23
|
+
- YES votes with high importance and a comment — those reveal what the user actively wants.
|
|
24
|
+
- NO votes with high importance — those reveal red lines.
|
|
25
|
+
- ABSTAIN votes — those reveal genuine uncertainty; the user wants you to help them think it through.
|
|
26
|
+
- Comments that contradict another vote, or that hint at a tradeoff the user would accept.
|
|
27
|
+
- Importance scores below ~0.3 — those tell you what NOT to spend a question on.
|
|
28
|
+
|
|
29
|
+
Also scan the constitution above (if any) for what's already settled, and the running transcript so you don't repeat yourself.
|
|
30
|
+
|
|
31
|
+
This is a chat, not a survey. Each of your replies has two parts:
|
|
32
|
+
|
|
33
|
+
1. REACT, briefly, to what the user just said in their previous message. One or two sentences. Reflect back what you heard, push back on a tension you noticed in their votes or earlier answers, or acknowledge a constraint — like a real conversation, not a form. Reference specific things they actually said or voted on when you can ("You voted yes on X with importance 0.8 — …"). Do NOT flatter, do NOT summarize the whole conversation, do NOT explain your methodology. If this is your very first turn (no user messages yet), skip the reaction and just open with a question that follows from their baseline.
|
|
34
|
+
|
|
35
|
+
2. ASK exactly one concrete next question, grounded in the baseline. Strongly prefer questions that reference something the user actually voted on or commented on. Probe, in roughly this order of usefulness: red lines they hinted at, tradeoffs they'd accept, relative priority between two issues they both care about, and contradictions in what they've said so far. The question should follow naturally from your reaction — if you noticed a tension, your question should test it.
|
|
36
|
+
|
|
37
|
+
Speak in first person. Keep your voice consistent with the constitution above. Avoid clichés like "that's a really thoughtful answer". Keep the whole reply under ~120 words; users read every word.
|
|
38
|
+
|
|
39
|
+
Output plain prose only. No JSON, no markdown headings, no bullet points — just the reaction (if any) followed by the question, written as you would say it out loud.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const TRAINING_EXTRACT_PROFILE_SYSTEM_PROMPT = `You are distilling a training conversation into a structured preference architecture for an AI political representative. Be faithful to the user's own words and votes. Do not invent positions they did not express. Mark anything you are inferring, rather than they stated, by lowering the confidence on that priority. Use short, plain labels.
|
|
43
|
+
|
|
44
|
+
Return strict JSON matching this TrainingProfile shape exactly:
|
|
45
|
+
{
|
|
46
|
+
"summary": "1-2 sentence overview",
|
|
47
|
+
"coreValues": ["3-7 short values"],
|
|
48
|
+
"issuePriorities": [
|
|
49
|
+
{
|
|
50
|
+
"issue": "short label",
|
|
51
|
+
"stance": "1 sentence",
|
|
52
|
+
"importance": 0.0,
|
|
53
|
+
"negotiability": 0.0,
|
|
54
|
+
"confidence": 0.0
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"redLines": ["0-6 items"],
|
|
58
|
+
"acceptableTradeoffs": ["0-6 items"],
|
|
59
|
+
"uncertaintyAreas": ["0-6 items"],
|
|
60
|
+
"representationRules": ["0-5 items"]
|
|
61
|
+
}
|
|
62
|
+
No prose outside JSON. Output strict JSON, nothing else.`;
|
|
63
|
+
|
|
64
|
+
export const TRAINING_ALIGNMENT_TEST_SYSTEM_PROMPT = `You are voting as a trained Simocracy political representative. Use the sim's current constitution and the structured training profile to decide how the sim should vote.
|
|
65
|
+
|
|
66
|
+
Vote only yes, no, or abstain. Explain your vote in fewer than 280 characters. Output strict JSON, nothing else.`;
|
|
67
|
+
|
|
68
|
+
export const TRAINING_MERGE_CONSTITUTION_SYSTEM_PROMPT = `You are merging a sim's training profile into its constitution. Your job is to produce a SINGLE COHERENT constitution that reads as a hand-written document — not a Frankenstein of old text plus appended sections.
|
|
69
|
+
|
|
70
|
+
You receive:
|
|
71
|
+
- The sim's existing constitution (markdown, may be empty).
|
|
72
|
+
- The sim's existing short description (one-liner used in previews).
|
|
73
|
+
- The sim's speaking style, if any (use it to keep voice consistent).
|
|
74
|
+
- A distilled training profile: summary, core values, issue priorities (each with stance, importance, negotiability, confidence), red lines, acceptable tradeoffs, uncertainty areas, representation rules.
|
|
75
|
+
|
|
76
|
+
Your output should:
|
|
77
|
+
1. PRESERVE the existing constitution's voice, structure, and positions still consistent with the profile. Never throw away well-written prose for no reason.
|
|
78
|
+
2. UPDATE existing positions that are now refined or contradicted by the profile.
|
|
79
|
+
3. ADD new positions from the profile that aren't covered yet — woven into the existing structure where natural, not appended at the bottom.
|
|
80
|
+
4. REMOVE or REWRITE stale positions the profile clearly contradicts.
|
|
81
|
+
5. Read as ONE consistent document. NO section labelled "Training Lab Profile", "Distilled positions", "Update from training", or any other meta-commentary that names the lab. The result should look like a hand-written constitution, not a generated artefact.
|
|
82
|
+
|
|
83
|
+
If the existing constitution is empty, write one from scratch using the profile as the source of truth — pick a structure that fits the sim's voice (typically: brief intro, core values, key positions, red lines, decision rules / how I vote).
|
|
84
|
+
|
|
85
|
+
Keep markdown formatting (## headings, **bold**, lists). Stay under ~2,500 words. Don't include placeholder text. Don't add disclaimers like "this constitution may evolve".
|
|
86
|
+
|
|
87
|
+
OUTPUT FORMAT — exactly two sections separated by a single line containing only "---":
|
|
88
|
+
- The first section is the new short description (one or two sentences, max 280 characters, no markdown). Suitable for previews and list views.
|
|
89
|
+
- The second section is the full markdown constitution.
|
|
90
|
+
|
|
91
|
+
Example output:
|
|
92
|
+
|
|
93
|
+
A free-software public-goods funder, sceptical of metric-driven funding, optimising for governance experiments and underrepresented communities.
|
|
94
|
+
---
|
|
95
|
+
## What I value
|
|
96
|
+
|
|
97
|
+
I fund projects that demonstrate strong community governance and serve underrepresented populations…
|
|
98
|
+
|
|
99
|
+
## How I evaluate proposals
|
|
100
|
+
|
|
101
|
+
…`;
|
|
102
|
+
|
|
103
|
+
export const DERIVE_FROM_INTERVIEW_SYSTEM_PROMPT = `You are a constitutional architect and communication style designer for governance simulation agents ("sims") in Simocracy.
|
|
104
|
+
|
|
105
|
+
You are given an interview transcript containing:
|
|
106
|
+
- Open-ended voice answers about the person's values, evaluation philosophy, and funding priorities
|
|
107
|
+
- Yes/no positions on value statements
|
|
108
|
+
|
|
109
|
+
From this interview, you must derive TWO things:
|
|
110
|
+
|
|
111
|
+
=== CONSTITUTION ===
|
|
112
|
+
SHORT: <one-sentence summary of this sim's core political identity, max 200 chars>
|
|
113
|
+
---
|
|
114
|
+
<full constitution in markdown, under 3000 chars, covering:>
|
|
115
|
+
## Core Beliefs
|
|
116
|
+
## Values & Principles
|
|
117
|
+
## Governance Positions
|
|
118
|
+
## Behavioral Guidelines
|
|
119
|
+
|
|
120
|
+
Use **bold**, *italic*, bullet lists, > blockquotes. Stay faithful to the interviewee's actual positions and voice.
|
|
121
|
+
|
|
122
|
+
=== STYLE ===
|
|
123
|
+
<speaking style guide in markdown, under 3000 chars, covering:>
|
|
124
|
+
## Tone & Register
|
|
125
|
+
## Vocabulary & Diction
|
|
126
|
+
## Mannerisms & Quirks
|
|
127
|
+
## Communication Patterns
|
|
128
|
+
|
|
129
|
+
Derive the style from HOW the person expressed themselves in the interview — their word choices, sentence structure, level of formality, use of examples, etc.
|
|
130
|
+
|
|
131
|
+
Output EXACTLY in this format with the delimiters shown. No other preamble.`;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question-set bootstrapping for the CLI Training Lab.
|
|
3
|
+
*
|
|
4
|
+
* Web parity: `simocracy-v2/lib/training/question-sets.ts`. The web
|
|
5
|
+
* Lab's BaselineTab auto-picks the facilitator-starred default
|
|
6
|
+
* template via the indexer; the CLI does the same by listing
|
|
7
|
+
* `org.simocracy.interviewTemplate` records and asking the user to
|
|
8
|
+
* pick one (single template = pick automatically; none = built-in
|
|
9
|
+
* fallback that mirrors `interview-modal.tsx`'s
|
|
10
|
+
* `buildFallbackTemplate`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import { searchInterviewTemplates, type LoadedInterviewTemplate } from "../simocracy.ts";
|
|
16
|
+
import type { BaselineProposal, BaselineQuestionSet, InterviewTemplateRecord } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
const FALLBACK_OPEN_QUESTIONS = [
|
|
19
|
+
"What's your personal definition of a \"high-impact project\"?",
|
|
20
|
+
"When you review a proposal, what are the top 3 things you look for?",
|
|
21
|
+
"What red flags make you skeptical about a proposal's credibility?",
|
|
22
|
+
"How do you balance measurable outcomes vs. long-term systemic change?",
|
|
23
|
+
"If you had limited resources, how would you decide which project to support?",
|
|
24
|
+
"Which values guide your evaluations the most (e.g., equity, scale, innovation, sustainability)?",
|
|
25
|
+
"Can you give an example of a project you'd enthusiastically fund, and why?",
|
|
26
|
+
"Can you give an example of a project you'd likely reject, and why?",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const FALLBACK_YESNO_STATEMENTS = [
|
|
30
|
+
"Projects with strong community governance should receive preference in funding.",
|
|
31
|
+
"Public goods that effectively demonstrate the benefit of public goods should receive more funding.",
|
|
32
|
+
"Environmental sustainability should be a key factor in funding decisions.",
|
|
33
|
+
"Innovation and experimental approaches should be prioritized over proven solutions.",
|
|
34
|
+
"Projects with measurable outcomes should be favored over those with difficult-to-quantify benefits.",
|
|
35
|
+
"Funders should be accountable for the funding they provide to the selected projects.",
|
|
36
|
+
"Humans are accountable for decisions made by an AI.",
|
|
37
|
+
"Public goods should prioritize immediate community needs over long-term systemic change.",
|
|
38
|
+
"Projects that already have a lot of support shouldn't receive additional donations for new small projects.",
|
|
39
|
+
"Cost effectiveness should be the primary criterion for funding allocation.",
|
|
40
|
+
"Funding decisions should consider geographic equity and underserved populations.",
|
|
41
|
+
"Projects that benefit the greatest number of people should be prioritized.",
|
|
42
|
+
"Funding should support projects with sustainable revenue models.",
|
|
43
|
+
"Open source software should be allocated the most funding compared to other categories.",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function buildFallbackTemplate(): InterviewTemplateRecord {
|
|
47
|
+
return {
|
|
48
|
+
$type: "org.simocracy.interviewTemplate",
|
|
49
|
+
name: "Default Constitution",
|
|
50
|
+
description: "Built-in fallback when the indexer returns no curated templates.",
|
|
51
|
+
questions: [
|
|
52
|
+
...FALLBACK_OPEN_QUESTIONS.map((prompt, i) => ({
|
|
53
|
+
id: `open-${i + 1}`,
|
|
54
|
+
type: "open" as const,
|
|
55
|
+
prompt,
|
|
56
|
+
})),
|
|
57
|
+
...FALLBACK_YESNO_STATEMENTS.map((prompt, i) => ({
|
|
58
|
+
id: `yesno-${i + 1}`,
|
|
59
|
+
type: "yesNo" as const,
|
|
60
|
+
prompt,
|
|
61
|
+
})),
|
|
62
|
+
],
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Convert the yes/no questions of a template into a BaselineQuestionSet.
|
|
69
|
+
* Mirrors `questionSetFromTemplate` in
|
|
70
|
+
* `simocracy-v2/lib/training/question-sets.ts`.
|
|
71
|
+
*/
|
|
72
|
+
export function questionSetFromTemplate(
|
|
73
|
+
template: InterviewTemplateRecord,
|
|
74
|
+
templateUri: string,
|
|
75
|
+
): BaselineQuestionSet {
|
|
76
|
+
const rkey = templateUri.split("/").pop() ?? "template";
|
|
77
|
+
const proposals: BaselineProposal[] = template.questions
|
|
78
|
+
.filter((q) => q.type === "yesNo")
|
|
79
|
+
.map((question, index) => ({
|
|
80
|
+
id: `template:${rkey}:${question.id || `q${index}`}`,
|
|
81
|
+
title: question.prompt,
|
|
82
|
+
summary: question.prompt,
|
|
83
|
+
topic: template.name,
|
|
84
|
+
}));
|
|
85
|
+
return {
|
|
86
|
+
source: "template",
|
|
87
|
+
templateUri,
|
|
88
|
+
templateName: template.name,
|
|
89
|
+
proposals,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pick an interview template via `ctx.ui.select`. Returns null if the
|
|
95
|
+
* user cancels. Falls back to the built-in template when the indexer
|
|
96
|
+
* returns nothing.
|
|
97
|
+
*/
|
|
98
|
+
export async function pickInterviewTemplate(
|
|
99
|
+
ctx: ExtensionCommandContext,
|
|
100
|
+
): Promise<LoadedInterviewTemplate | null> {
|
|
101
|
+
ctx.ui.notify("Loading interview templates…", "info");
|
|
102
|
+
let templates: LoadedInterviewTemplate[] = [];
|
|
103
|
+
try {
|
|
104
|
+
templates = await searchInterviewTemplates(100);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
`Indexer template fetch failed: ${(err as Error).message}. Using built-in fallback.`,
|
|
108
|
+
"warning",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (templates.length === 0) {
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
"No curated templates available — using built-in fallback questionnaire.",
|
|
115
|
+
"info",
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
uri: "",
|
|
119
|
+
cid: "",
|
|
120
|
+
did: "",
|
|
121
|
+
rkey: "",
|
|
122
|
+
template: buildFallbackTemplate(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const labels = templates.map((t) => {
|
|
127
|
+
const yesNoCount = t.template.questions.filter((q) => q.type === "yesNo").length;
|
|
128
|
+
return `${t.template.name} — ${yesNoCount} yes/no, ${t.template.questions.length} total`;
|
|
129
|
+
});
|
|
130
|
+
const picked = await ctx.ui.select("Choose an interview template", labels);
|
|
131
|
+
if (!picked) return null;
|
|
132
|
+
const idx = labels.indexOf(picked);
|
|
133
|
+
return templates[idx] ?? null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed Training Lab state for the pi-simocracy CLI.
|
|
3
|
+
*
|
|
4
|
+
* Web parity: simocracy-v2 stores `TrainingLabState` in localStorage
|
|
5
|
+
* keyed by `simocracy.trainingLab.v1.<simUri>`. The CLI has no
|
|
6
|
+
* localStorage, so we mirror the schema to a JSON file under the
|
|
7
|
+
* platform's XDG data dir, keyed by the sim's rkey (the loaded sim's
|
|
8
|
+
* AT-URI rkey is unique within the user's PDS).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import type { TrainingLabState } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
const DATA_DIR = process.env.XDG_DATA_HOME
|
|
18
|
+
? join(process.env.XDG_DATA_HOME, "pi-simocracy", "training")
|
|
19
|
+
: join(homedir(), ".local", "share", "pi-simocracy", "training");
|
|
20
|
+
|
|
21
|
+
export function trainingFilePath(rkey: string): string {
|
|
22
|
+
return join(DATA_DIR, `${rkey}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function trainingDir(): string {
|
|
26
|
+
return DATA_DIR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureDir(): void {
|
|
30
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createEmptyTrainingLabState(): TrainingLabState {
|
|
34
|
+
return {
|
|
35
|
+
baselineVotes: [],
|
|
36
|
+
interviewTurns: [],
|
|
37
|
+
feedbackTurns: [],
|
|
38
|
+
profile: null,
|
|
39
|
+
alignment: null,
|
|
40
|
+
updatedAt: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function loadTrainingLabState(rkey: string): TrainingLabState {
|
|
45
|
+
const path = trainingFilePath(rkey);
|
|
46
|
+
if (!existsSync(path)) return createEmptyTrainingLabState();
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(path, "utf8");
|
|
49
|
+
const parsed = JSON.parse(raw) as Partial<TrainingLabState>;
|
|
50
|
+
return {
|
|
51
|
+
baselineVotes: parsed.baselineVotes ?? [],
|
|
52
|
+
interviewTurns: parsed.interviewTurns ?? [],
|
|
53
|
+
feedbackTurns: parsed.feedbackTurns ?? [],
|
|
54
|
+
profile: parsed.profile ?? null,
|
|
55
|
+
alignment: parsed.alignment ?? null,
|
|
56
|
+
questionSet: parsed.questionSet,
|
|
57
|
+
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(`[pi-simocracy] Could not parse training state at ${path}:`, (err as Error).message);
|
|
61
|
+
return createEmptyTrainingLabState();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveTrainingLabState(rkey: string, state: TrainingLabState): void {
|
|
66
|
+
ensureDir();
|
|
67
|
+
const path = trainingFilePath(rkey);
|
|
68
|
+
const next: TrainingLabState = { ...state, updatedAt: new Date().toISOString() };
|
|
69
|
+
writeFileSync(path, JSON.stringify(next, null, 2), "utf8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clearTrainingLabState(rkey: string): boolean {
|
|
73
|
+
const path = trainingFilePath(rkey);
|
|
74
|
+
if (!existsSync(path)) return false;
|
|
75
|
+
rmSync(path, { force: true });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function trainingStateExists(rkey: string): boolean {
|
|
80
|
+
return existsSync(trainingFilePath(rkey));
|
|
81
|
+
}
|