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/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 +646 -64
- package/src/interview.ts +516 -0
- package/src/openrouter.ts +16 -0
- package/src/persona.ts +60 -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
package/src/interview.ts
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/sim interview` — port of simocracy-v2's `interview-modal.tsx` to
|
|
3
|
+
* the terminal. Runs the questionnaire (open + yes/no questions),
|
|
4
|
+
* lets the user review answers, then derives a constitution + style
|
|
5
|
+
* via OpenRouter. PR 2 prints the result and tells the user to
|
|
6
|
+
* paste into simocracy.org. PR 3 will write to PDS via OAuth.
|
|
7
|
+
*
|
|
8
|
+
* Skips ElevenLabs (voice) entirely — terminal is text-only. Open
|
|
9
|
+
* questions are answered as multi-line text via the editor primitive
|
|
10
|
+
* when available, falling back to single-line input.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionCommandContext,
|
|
16
|
+
} from "@mariozechner/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
import type { LoadedSim } from "./persona.ts";
|
|
19
|
+
import { openRouterComplete, TRAINING_CHAT_MODEL } from "./openrouter.ts";
|
|
20
|
+
import { DERIVE_FROM_INTERVIEW_SYSTEM_PROMPT } from "./training/prompts.ts";
|
|
21
|
+
import {
|
|
22
|
+
pickInterviewTemplate,
|
|
23
|
+
buildFallbackTemplate,
|
|
24
|
+
} from "./training/question-set.ts";
|
|
25
|
+
import {
|
|
26
|
+
searchInterviewTemplates,
|
|
27
|
+
fetchInterviewTemplateByUri,
|
|
28
|
+
resolveHandle,
|
|
29
|
+
type LoadedInterviewTemplate,
|
|
30
|
+
} from "./simocracy.ts";
|
|
31
|
+
import { readAuth } from "./auth/storage.ts";
|
|
32
|
+
import {
|
|
33
|
+
createAgents,
|
|
34
|
+
createInterview,
|
|
35
|
+
createStyle,
|
|
36
|
+
findRkeyForSim,
|
|
37
|
+
getAuthenticatedAgent,
|
|
38
|
+
NotSignedInError,
|
|
39
|
+
updateAgents,
|
|
40
|
+
updateStyle,
|
|
41
|
+
} from "./writes.ts";
|
|
42
|
+
|
|
43
|
+
interface OpenAnswer {
|
|
44
|
+
question: string;
|
|
45
|
+
answer: string;
|
|
46
|
+
}
|
|
47
|
+
interface YesNoAnswer {
|
|
48
|
+
statement: string;
|
|
49
|
+
answer: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface InterviewResult {
|
|
53
|
+
openAnswers: OpenAnswer[];
|
|
54
|
+
yesNoAnswers: YesNoAnswer[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DerivedSim {
|
|
58
|
+
constitution: { shortDescription: string; description: string };
|
|
59
|
+
style: { description: string };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RunInterviewOptions {
|
|
63
|
+
templateUri?: string;
|
|
64
|
+
/** When true, skip the picker and just use the first matching template. */
|
|
65
|
+
pickFirst?: boolean;
|
|
66
|
+
/** When true, after deriving, write to the user's PDS via OAuth. */
|
|
67
|
+
apply?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drive the interactive interview flow. Returns null if the user
|
|
72
|
+
* cancels at any point.
|
|
73
|
+
*/
|
|
74
|
+
export async function runInterviewFlow(
|
|
75
|
+
pi: ExtensionAPI,
|
|
76
|
+
ctx: ExtensionCommandContext,
|
|
77
|
+
loadedSim: LoadedSim,
|
|
78
|
+
opts: RunInterviewOptions = {},
|
|
79
|
+
): Promise<{ result: InterviewResult; derived: DerivedSim | null } | null> {
|
|
80
|
+
void pi;
|
|
81
|
+
// 1. Pick template (or use the prop).
|
|
82
|
+
let template: LoadedInterviewTemplate | null = null;
|
|
83
|
+
if (opts.templateUri) {
|
|
84
|
+
template = await fetchInterviewTemplateByUri(opts.templateUri);
|
|
85
|
+
if (!template) {
|
|
86
|
+
ctx.ui.notify(
|
|
87
|
+
`Couldn't load template ${opts.templateUri}, falling back to picker.`,
|
|
88
|
+
"warning",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!template) {
|
|
93
|
+
template = await pickInterviewTemplate(ctx);
|
|
94
|
+
}
|
|
95
|
+
if (!template) {
|
|
96
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ctx.ui.notify(
|
|
101
|
+
`Interview: ${template.template.name} — ${template.template.questions.length} questions.`,
|
|
102
|
+
"info",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// 2. Run the questionnaire.
|
|
106
|
+
const result = await runQuestionnaire(ctx, template);
|
|
107
|
+
if (!result) return null;
|
|
108
|
+
|
|
109
|
+
// 3. Review.
|
|
110
|
+
const reviewed = await reviewAnswers(ctx, template, result);
|
|
111
|
+
if (!reviewed) return null;
|
|
112
|
+
|
|
113
|
+
// 4. Derive (read-only — no PDS write in PR 2).
|
|
114
|
+
ctx.ui.notify("Deriving constitution + style…", "info");
|
|
115
|
+
let derived: DerivedSim | null = null;
|
|
116
|
+
try {
|
|
117
|
+
derived = await deriveFromInterview(loadedSim, reviewed);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
ctx.ui.notify(`Derivation failed: ${(err as Error).message}`, "error");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (derived) {
|
|
123
|
+
printDerived(loadedSim, derived);
|
|
124
|
+
if (opts.apply) {
|
|
125
|
+
await applyDerivedToPds(ctx, loadedSim, template, reviewed, derived);
|
|
126
|
+
} else {
|
|
127
|
+
ctx.ui.notify(
|
|
128
|
+
"To save: copy the output into the constitution + style editors at simocracy.org, or sign into ATProto via `/sim login <handle>` and re-run with --apply.",
|
|
129
|
+
"info",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { result: reviewed, derived };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function applyDerivedToPds(
|
|
138
|
+
ctx: ExtensionCommandContext,
|
|
139
|
+
loadedSim: LoadedSim,
|
|
140
|
+
template: LoadedInterviewTemplate,
|
|
141
|
+
result: InterviewResult,
|
|
142
|
+
derived: DerivedSim,
|
|
143
|
+
): Promise<boolean> {
|
|
144
|
+
const auth = readAuth();
|
|
145
|
+
if (!auth) {
|
|
146
|
+
ctx.ui.notify("Not signed into ATProto. Run `/sim login <handle>` first (e.g. `/sim login alice.bsky.social`).", "error");
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (auth.did !== loadedSim.did) {
|
|
150
|
+
const ownerHandle =
|
|
151
|
+
loadedSim.handle ?? (await resolveHandle(loadedSim.did).catch(() => null));
|
|
152
|
+
ctx.ui.notify(
|
|
153
|
+
`You can only apply to sims you own. Loaded sim is owned by ${
|
|
154
|
+
ownerHandle ? `@${ownerHandle}` : loadedSim.did
|
|
155
|
+
} — your signed-in DID is ${auth.did}.`,
|
|
156
|
+
"error",
|
|
157
|
+
);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let agent;
|
|
162
|
+
try {
|
|
163
|
+
({ agent } = await getAuthenticatedAgent());
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof NotSignedInError) ctx.ui.notify(err.message, "error");
|
|
166
|
+
else ctx.ui.notify(`Auth failed: ${(err as Error).message}`, "error");
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
ctx.ui.notify("Writing interview record…", "info");
|
|
171
|
+
try {
|
|
172
|
+
await createInterview({
|
|
173
|
+
agent,
|
|
174
|
+
did: auth.did,
|
|
175
|
+
simUri: loadedSim.uri,
|
|
176
|
+
simCid: "",
|
|
177
|
+
openAnswers: result.openAnswers.map((a) => ({
|
|
178
|
+
question: a.question,
|
|
179
|
+
answer: a.answer,
|
|
180
|
+
})),
|
|
181
|
+
yesNoAnswers: result.yesNoAnswers.map((a) => ({
|
|
182
|
+
statement: a.statement,
|
|
183
|
+
answer: a.answer,
|
|
184
|
+
})),
|
|
185
|
+
templateUri: template.uri || undefined,
|
|
186
|
+
templateCid: template.cid || undefined,
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
ctx.ui.notify(`Interview write failed: ${(err as Error).message}`, "error");
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Now create-or-update the agents + style records to match the
|
|
194
|
+
// derived constitution. Mirrors `derive-from-interview/route.ts`'s
|
|
195
|
+
// flow on simocracy-v2.
|
|
196
|
+
const existingAgents = await findRkeyForSim(
|
|
197
|
+
agent,
|
|
198
|
+
auth.did,
|
|
199
|
+
"org.simocracy.agents",
|
|
200
|
+
loadedSim.uri,
|
|
201
|
+
).catch(() => null);
|
|
202
|
+
ctx.ui.notify(
|
|
203
|
+
existingAgents
|
|
204
|
+
? `Updating org.simocracy.agents (${existingAgents})…`
|
|
205
|
+
: "Creating org.simocracy.agents…",
|
|
206
|
+
"info",
|
|
207
|
+
);
|
|
208
|
+
try {
|
|
209
|
+
if (existingAgents) {
|
|
210
|
+
await updateAgents({
|
|
211
|
+
agent,
|
|
212
|
+
did: auth.did,
|
|
213
|
+
rkey: existingAgents,
|
|
214
|
+
simUri: loadedSim.uri,
|
|
215
|
+
simCid: "",
|
|
216
|
+
shortDescription: derived.constitution.shortDescription,
|
|
217
|
+
description: derived.constitution.description,
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
await createAgents({
|
|
221
|
+
agent,
|
|
222
|
+
did: auth.did,
|
|
223
|
+
simUri: loadedSim.uri,
|
|
224
|
+
simCid: "",
|
|
225
|
+
shortDescription: derived.constitution.shortDescription,
|
|
226
|
+
description: derived.constitution.description,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
ctx.ui.notify(`Agents write failed: ${(err as Error).message}`, "error");
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const existingStyle = await findRkeyForSim(
|
|
235
|
+
agent,
|
|
236
|
+
auth.did,
|
|
237
|
+
"org.simocracy.style",
|
|
238
|
+
loadedSim.uri,
|
|
239
|
+
).catch(() => null);
|
|
240
|
+
ctx.ui.notify(
|
|
241
|
+
existingStyle ? `Updating org.simocracy.style (${existingStyle})…` : "Creating org.simocracy.style…",
|
|
242
|
+
"info",
|
|
243
|
+
);
|
|
244
|
+
try {
|
|
245
|
+
if (existingStyle) {
|
|
246
|
+
await updateStyle({
|
|
247
|
+
agent,
|
|
248
|
+
did: auth.did,
|
|
249
|
+
rkey: existingStyle,
|
|
250
|
+
simUri: loadedSim.uri,
|
|
251
|
+
simCid: "",
|
|
252
|
+
description: derived.style.description,
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
await createStyle({
|
|
256
|
+
agent,
|
|
257
|
+
did: auth.did,
|
|
258
|
+
simUri: loadedSim.uri,
|
|
259
|
+
simCid: "",
|
|
260
|
+
description: derived.style.description,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
ctx.ui.notify(`Style write failed: ${(err as Error).message}`, "error");
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
ctx.ui.notify(
|
|
269
|
+
`Saved interview, constitution, and speaking style to ${
|
|
270
|
+
auth.handle ? `@${auth.handle}` : auth.did
|
|
271
|
+
}'s PDS.`,
|
|
272
|
+
"info",
|
|
273
|
+
);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Headless variant for the LLM-callable tool: returns the template
|
|
279
|
+
* structure as a planning aid when there's no UI to drive it.
|
|
280
|
+
*/
|
|
281
|
+
export async function snapshotInterviewTemplate(
|
|
282
|
+
templateUri?: string,
|
|
283
|
+
): Promise<{ name: string; questions: { id: string; type: string; prompt: string }[] }> {
|
|
284
|
+
let tpl: LoadedInterviewTemplate | null = null;
|
|
285
|
+
if (templateUri) {
|
|
286
|
+
tpl = await fetchInterviewTemplateByUri(templateUri);
|
|
287
|
+
}
|
|
288
|
+
if (!tpl) {
|
|
289
|
+
const list = await searchInterviewTemplates(10).catch(() => []);
|
|
290
|
+
tpl = list[0] ?? {
|
|
291
|
+
uri: "",
|
|
292
|
+
cid: "",
|
|
293
|
+
did: "",
|
|
294
|
+
rkey: "",
|
|
295
|
+
template: buildFallbackTemplate(),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
name: tpl.template.name,
|
|
300
|
+
questions: tpl.template.questions.map((q) => ({
|
|
301
|
+
id: q.id,
|
|
302
|
+
type: q.type,
|
|
303
|
+
prompt: q.prompt,
|
|
304
|
+
})),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function runQuestionnaire(
|
|
309
|
+
ctx: ExtensionCommandContext,
|
|
310
|
+
template: LoadedInterviewTemplate,
|
|
311
|
+
): Promise<InterviewResult | null> {
|
|
312
|
+
const open: OpenAnswer[] = [];
|
|
313
|
+
const yesNo: YesNoAnswer[] = [];
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < template.template.questions.length; i++) {
|
|
316
|
+
const q = template.template.questions[i];
|
|
317
|
+
const header = `[${i + 1}/${template.template.questions.length}]`;
|
|
318
|
+
|
|
319
|
+
if (q.type === "open" || q.type === "text") {
|
|
320
|
+
const answer = await askOpen(ctx, header, q.prompt);
|
|
321
|
+
if (answer === null) return null;
|
|
322
|
+
if (answer.trim()) open.push({ question: q.prompt, answer: answer.trim() });
|
|
323
|
+
} else if (q.type === "yesNo") {
|
|
324
|
+
const choice = await ctx.ui.select(`${header} ${q.prompt}`, [
|
|
325
|
+
"Agree",
|
|
326
|
+
"Disagree",
|
|
327
|
+
"Skip",
|
|
328
|
+
]);
|
|
329
|
+
if (choice === undefined) return null;
|
|
330
|
+
if (choice === "Skip") continue;
|
|
331
|
+
yesNo.push({ statement: q.prompt, answer: choice === "Agree" });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { openAnswers: open, yesNoAnswers: yesNo };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function askOpen(
|
|
339
|
+
ctx: ExtensionCommandContext,
|
|
340
|
+
header: string,
|
|
341
|
+
prompt: string,
|
|
342
|
+
): Promise<string | null> {
|
|
343
|
+
// Prefer the multi-line editor when available — open questions
|
|
344
|
+
// expect 2-4 sentences of prose.
|
|
345
|
+
try {
|
|
346
|
+
const text = await ctx.ui.editor(`${header} ${prompt}`);
|
|
347
|
+
if (text === undefined) return null;
|
|
348
|
+
return text;
|
|
349
|
+
} catch {
|
|
350
|
+
const text = await ctx.ui.input(`${header} ${prompt}`, "Your answer (Enter to skip)");
|
|
351
|
+
if (text === undefined) return null;
|
|
352
|
+
return text;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function reviewAnswers(
|
|
357
|
+
ctx: ExtensionCommandContext,
|
|
358
|
+
template: LoadedInterviewTemplate,
|
|
359
|
+
result: InterviewResult,
|
|
360
|
+
): Promise<InterviewResult | null> {
|
|
361
|
+
const summarise = (r: InterviewResult) => {
|
|
362
|
+
const lines = [`\nReview (${template.template.name}):\n`];
|
|
363
|
+
if (r.openAnswers.length) {
|
|
364
|
+
lines.push("Open answers:");
|
|
365
|
+
for (const a of r.openAnswers) {
|
|
366
|
+
lines.push(` • ${a.question}`);
|
|
367
|
+
lines.push(` ${a.answer.replace(/\n/g, "\n ")}`);
|
|
368
|
+
}
|
|
369
|
+
lines.push("");
|
|
370
|
+
}
|
|
371
|
+
if (r.yesNoAnswers.length) {
|
|
372
|
+
lines.push("Value positions:");
|
|
373
|
+
for (const a of r.yesNoAnswers) {
|
|
374
|
+
const verdict = a.answer ? "Agree " : "Disagree";
|
|
375
|
+
lines.push(` ${verdict} ${a.statement}`);
|
|
376
|
+
}
|
|
377
|
+
lines.push("");
|
|
378
|
+
}
|
|
379
|
+
console.log(lines.join("\n"));
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
let current = result;
|
|
383
|
+
for (;;) {
|
|
384
|
+
summarise(current);
|
|
385
|
+
const next = await ctx.ui.select("Review your interview", [
|
|
386
|
+
"Continue — derive constitution + style",
|
|
387
|
+
"Edit an answer",
|
|
388
|
+
"Cancel",
|
|
389
|
+
]);
|
|
390
|
+
if (next === undefined || next === "Cancel") return null;
|
|
391
|
+
if (next.startsWith("Continue")) return current;
|
|
392
|
+
|
|
393
|
+
const labels = current.openAnswers.map((a, i) => `${i + 1}. ${a.question}`);
|
|
394
|
+
if (labels.length === 0) {
|
|
395
|
+
ctx.ui.notify("No open answers to edit.", "info");
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const picked = await ctx.ui.select("Edit which answer?", labels);
|
|
399
|
+
if (!picked) continue;
|
|
400
|
+
const idx = labels.indexOf(picked);
|
|
401
|
+
const old = current.openAnswers[idx];
|
|
402
|
+
let updated: string | undefined;
|
|
403
|
+
try {
|
|
404
|
+
updated = await ctx.ui.editor(`Edit: ${old.question}`, old.answer);
|
|
405
|
+
} catch {
|
|
406
|
+
updated = await ctx.ui.input(`Edit: ${old.question}`, old.answer);
|
|
407
|
+
}
|
|
408
|
+
if (updated === undefined) continue;
|
|
409
|
+
const trimmed = updated.trim();
|
|
410
|
+
if (!trimmed) {
|
|
411
|
+
current = {
|
|
412
|
+
...current,
|
|
413
|
+
openAnswers: current.openAnswers.filter((_, i) => i !== idx),
|
|
414
|
+
};
|
|
415
|
+
} else {
|
|
416
|
+
const nextOpen = [...current.openAnswers];
|
|
417
|
+
nextOpen[idx] = { ...old, answer: trimmed };
|
|
418
|
+
current = { ...current, openAnswers: nextOpen };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function deriveFromInterview(
|
|
424
|
+
loadedSim: LoadedSim,
|
|
425
|
+
result: InterviewResult,
|
|
426
|
+
): Promise<DerivedSim | null> {
|
|
427
|
+
void loadedSim; // Reserved for future per-sim variations.
|
|
428
|
+
const openSection =
|
|
429
|
+
result.openAnswers.length > 0
|
|
430
|
+
? result.openAnswers
|
|
431
|
+
.map((a, i) => `Q${i + 1}: ${a.question}\nA${i + 1}: ${a.answer}`)
|
|
432
|
+
.join("\n\n")
|
|
433
|
+
: "(No open-ended responses provided)";
|
|
434
|
+
|
|
435
|
+
const yesNoSection =
|
|
436
|
+
result.yesNoAnswers.length > 0
|
|
437
|
+
? result.yesNoAnswers
|
|
438
|
+
.map((a) => `- "${a.statement}" → ${a.answer ? "Agree" : "Disagree"}`)
|
|
439
|
+
.join("\n")
|
|
440
|
+
: "(No value positions provided)";
|
|
441
|
+
|
|
442
|
+
const userMessage = `Here is the interview transcript for a sim:
|
|
443
|
+
|
|
444
|
+
## Open-Ended Responses
|
|
445
|
+
|
|
446
|
+
${openSection}
|
|
447
|
+
|
|
448
|
+
## Value Positions
|
|
449
|
+
|
|
450
|
+
${yesNoSection}`;
|
|
451
|
+
|
|
452
|
+
const content = await openRouterComplete(
|
|
453
|
+
[
|
|
454
|
+
{ role: "system", content: DERIVE_FROM_INTERVIEW_SYSTEM_PROMPT },
|
|
455
|
+
{ role: "user", content: userMessage },
|
|
456
|
+
],
|
|
457
|
+
{ model: TRAINING_CHAT_MODEL, maxTokens: 3000, temperature: 0.8 },
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
return parseDerivedOutput(content);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Parse the `=== CONSTITUTION === / === STYLE ===` delimited output
|
|
465
|
+
* the same way `app/api/derive-from-interview/route.ts` does.
|
|
466
|
+
*/
|
|
467
|
+
export function parseDerivedOutput(content: string): DerivedSim | null {
|
|
468
|
+
const generated = content.trim();
|
|
469
|
+
const constitutionMarker = "=== CONSTITUTION ===";
|
|
470
|
+
const styleMarker = "=== STYLE ===";
|
|
471
|
+
const cIdx = generated.indexOf(constitutionMarker);
|
|
472
|
+
const sIdx = generated.indexOf(styleMarker);
|
|
473
|
+
if (cIdx === -1 || sIdx === -1) return null;
|
|
474
|
+
|
|
475
|
+
const constitutionSection = generated.slice(cIdx + constitutionMarker.length, sIdx).trim();
|
|
476
|
+
const styleSection = generated.slice(sIdx + styleMarker.length).trim();
|
|
477
|
+
|
|
478
|
+
let shortDescription = "";
|
|
479
|
+
let constitutionMarkdown = constitutionSection;
|
|
480
|
+
const delimiter = constitutionSection.indexOf("\n---\n");
|
|
481
|
+
if (delimiter !== -1) {
|
|
482
|
+
const header = constitutionSection.slice(0, delimiter).trim();
|
|
483
|
+
constitutionMarkdown = constitutionSection.slice(delimiter + 5).trim();
|
|
484
|
+
if (header.startsWith("SHORT:")) {
|
|
485
|
+
shortDescription = header.slice(6).trim().slice(0, 300);
|
|
486
|
+
} else {
|
|
487
|
+
shortDescription = header.slice(0, 300);
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
const firstSentenceEnd = constitutionSection.search(/[.!?]\s/);
|
|
491
|
+
if (firstSentenceEnd > 0 && firstSentenceEnd < 300) {
|
|
492
|
+
shortDescription = constitutionSection.slice(0, firstSentenceEnd + 1).trim();
|
|
493
|
+
constitutionMarkdown = constitutionSection.slice(firstSentenceEnd + 1).trim();
|
|
494
|
+
} else {
|
|
495
|
+
shortDescription = constitutionSection.slice(0, 200).trim();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
constitution: { shortDescription, description: constitutionMarkdown },
|
|
501
|
+
style: { description: styleSection },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function printDerived(loadedSim: LoadedSim, derived: DerivedSim): void {
|
|
506
|
+
console.log("");
|
|
507
|
+
console.log(`Short description for ${loadedSim.name}:`);
|
|
508
|
+
console.log(derived.constitution.shortDescription);
|
|
509
|
+
console.log("");
|
|
510
|
+
console.log(`Constitution (markdown):`);
|
|
511
|
+
console.log(derived.constitution.description);
|
|
512
|
+
console.log("");
|
|
513
|
+
console.log(`Speaking style (markdown):`);
|
|
514
|
+
console.log(derived.style.description);
|
|
515
|
+
console.log("");
|
|
516
|
+
}
|
package/src/openrouter.ts
CHANGED
|
@@ -8,6 +8,22 @@
|
|
|
8
8
|
|
|
9
9
|
const DEFAULT_MODEL = "google/gemini-2.5-flash-lite";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Chat model for the Training Lab + interview flows. Mirrors
|
|
13
|
+
* `DEFAULT_CHAT_MODEL` in `simocracy-v2/lib/openrouter.ts` — keep in
|
|
14
|
+
* sync. Override via `DEFAULT_CHAT_MODEL` env var.
|
|
15
|
+
*/
|
|
16
|
+
export const TRAINING_CHAT_MODEL =
|
|
17
|
+
process.env.DEFAULT_CHAT_MODEL ?? "google/gemini-3.1-flash-lite-preview";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reasoning model used by the merge-constitution flow. Mirrors
|
|
21
|
+
* `DEFAULT_REASONING_MODEL` in `simocracy-v2/lib/openrouter.ts` —
|
|
22
|
+
* keep in sync. Override via `DEFAULT_REASONING_MODEL` env var.
|
|
23
|
+
*/
|
|
24
|
+
export const TRAINING_REASONING_MODEL =
|
|
25
|
+
process.env.DEFAULT_REASONING_MODEL ?? "~google/gemini-pro-latest";
|
|
26
|
+
|
|
11
27
|
export interface ChatMessage {
|
|
12
28
|
role: "system" | "user" | "assistant";
|
|
13
29
|
content: string;
|
package/src/persona.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loaded-sim persona representation + system-prompt builder.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `index.ts` so the Training Lab feedback loop can
|
|
5
|
+
* reuse the exact same persona block pi injects on every turn — that
|
|
6
|
+
* keeps "chat with the sim about its constitution" consistent with
|
|
7
|
+
* normal `/sim` chat and with the `simocracy_chat` tool.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface LoadedSim {
|
|
11
|
+
uri: string;
|
|
12
|
+
did: string;
|
|
13
|
+
rkey: string;
|
|
14
|
+
name: string;
|
|
15
|
+
handle: string | null;
|
|
16
|
+
shortDescription?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
style?: string;
|
|
19
|
+
/** Pre-rendered colored ANSI art of the sim's sprite. */
|
|
20
|
+
spriteAnsi?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the system-prompt persona block for a loaded sim. Same
|
|
25
|
+
* structure pi injects on every turn via `before_agent_start`.
|
|
26
|
+
*/
|
|
27
|
+
export function buildSimPrompt(sim: LoadedSim): string {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
lines.push(`# Simocracy roleplay`);
|
|
30
|
+
lines.push(
|
|
31
|
+
`You are now roleplaying as **${sim.name}**, a Simocracy sim — a simulated political agent in a decentralized governance simulation built on the AT Protocol.`,
|
|
32
|
+
);
|
|
33
|
+
lines.push(
|
|
34
|
+
`Stay in character at all times. Respond as ${sim.name} would — with their beliefs, values, and personality. Use first person. Don't break character or mention that you are an AI.`,
|
|
35
|
+
);
|
|
36
|
+
if (sim.handle) lines.push(`The sim's owner on ATProto is @${sim.handle} (${sim.did}).`);
|
|
37
|
+
if (sim.shortDescription) {
|
|
38
|
+
lines.push(``);
|
|
39
|
+
lines.push(`## ${sim.name}'s identity`);
|
|
40
|
+
lines.push(sim.shortDescription);
|
|
41
|
+
}
|
|
42
|
+
if (sim.description) {
|
|
43
|
+
lines.push(``);
|
|
44
|
+
lines.push(`## ${sim.name}'s constitution`);
|
|
45
|
+
lines.push(sim.description);
|
|
46
|
+
}
|
|
47
|
+
if (sim.style) {
|
|
48
|
+
lines.push(``);
|
|
49
|
+
lines.push(`## ${sim.name}'s speaking style`);
|
|
50
|
+
lines.push(sim.style);
|
|
51
|
+
}
|
|
52
|
+
lines.push(``);
|
|
53
|
+
lines.push(
|
|
54
|
+
`When the user asks you to use any of pi's tools (read, edit, bash, etc.), you should still use them — you're ${sim.name} *with access to a developer's terminal*. Just narrate tool use the way ${sim.name} would talk about it.`,
|
|
55
|
+
);
|
|
56
|
+
lines.push(
|
|
57
|
+
`Keep replies conversational unless the user explicitly asks for code or a long answer.`,
|
|
58
|
+
);
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|