pi-simocracy 0.2.0 → 0.3.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/README.md +49 -14
- package/package.json +1 -1
- package/src/auth/commands.ts +4 -4
- package/src/index.ts +260 -426
- package/src/openrouter.ts +0 -16
- package/src/simocracy.ts +3 -89
- package/src/writes.ts +117 -53
- package/src/interview.ts +0 -516
- package/src/training/alignment.ts +0 -259
- package/src/training/apply.ts +0 -271
- package/src/training/baseline.ts +0 -159
- package/src/training/chat.ts +0 -131
- package/src/training/feedback.ts +0 -81
- package/src/training/index.ts +0 -142
- package/src/training/profile.ts +0 -229
- package/src/training/prompt-helpers.ts +0 -70
- package/src/training/prompts.ts +0 -131
- package/src/training/question-set.ts +0 -134
- package/src/training/storage.ts +0 -81
- package/src/training/types.ts +0 -121
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-simocracy — load a Simocracy sim into your pi chat,
|
|
3
|
-
* constitution
|
|
2
|
+
* pi-simocracy — load a Simocracy sim into your pi chat, refine its
|
|
3
|
+
* constitution + speaking style by chatting with pi, and write the
|
|
4
|
+
* result back to your ATProto PDS.
|
|
4
5
|
*
|
|
5
6
|
* Sim commands:
|
|
6
7
|
* - `/sim <name>` Load a sim by name (fuzzy search on the indexer).
|
|
@@ -10,57 +11,43 @@
|
|
|
10
11
|
* - `/sim unload` Drop the loaded sim and stop roleplaying.
|
|
11
12
|
* - `/sim status` Show the currently loaded sim, if any.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Conversational training round — the sim asks targeted
|
|
22
|
-
* questions about gaps it sees in the baseline votes.
|
|
23
|
-
* - `/sim train profile`
|
|
24
|
-
* Distill the baseline + chat transcript into a structured
|
|
25
|
-
* TrainingProfile (priorities, red lines, tradeoffs).
|
|
26
|
-
* - `/sim train alignment`
|
|
27
|
-
* Score the sim against your hidden baseline and report match%.
|
|
28
|
-
* - `/sim train apply [--apply]`
|
|
29
|
-
* Merge the profile into the constitution. Without `--apply`
|
|
30
|
-
* copies the merged markdown to clipboard. With `--apply`
|
|
31
|
-
* writes to your PDS (requires `/sim login`).
|
|
32
|
-
* - `/sim train feedback`, `status`, `reset`
|
|
33
|
-
* Free-form feedback chat, status, and clear local state.
|
|
14
|
+
* Editing your sim's constitution / speaking style:
|
|
15
|
+
* There is no `/sim train` or `/sim interview` slash flow. Instead,
|
|
16
|
+
* load a sim you own and tell pi how you want the persona to change
|
|
17
|
+
* ("add a red line about animal welfare", "make the speaking style
|
|
18
|
+
* punchier and drop the lenny faces", etc.). Pi rewrites the
|
|
19
|
+
* constitution and/or speaking style and calls the
|
|
20
|
+
* `simocracy_update_sim` tool to write the result to your PDS.
|
|
21
|
+
* Requires `/sim login` and ownership of the loaded sim.
|
|
34
22
|
*
|
|
35
23
|
* ATProto sign-in ("sign in with Bluesky / ATProto", NOT Anthropic):
|
|
36
24
|
* - `/sim login [handle]`
|
|
37
25
|
* Loopback OAuth flow — opens your PDS's authorize page in the
|
|
38
26
|
* browser, grants this CLI a DPoP-bound session, persists it to
|
|
39
|
-
* ~/.config/pi-simocracy/auth.json. Required before
|
|
40
|
-
*
|
|
27
|
+
* ~/.config/pi-simocracy/auth.json. Required before pi can
|
|
28
|
+
* update your sim's constitution / style.
|
|
41
29
|
* - `/sim logout` Clear the local OAuth session.
|
|
42
30
|
* - `/sim whoami` Show the currently signed-in ATProto handle/DID.
|
|
43
31
|
*
|
|
44
32
|
* Browse your own sims (requires `/sim login`):
|
|
45
|
-
* - `/sim my`
|
|
46
|
-
* the signed-in DID
|
|
47
|
-
*
|
|
48
|
-
*
|
|
33
|
+
* - `/sim my` Pick from the org.simocracy.sim records owned
|
|
34
|
+
* by the signed-in DID. Single sim auto-loads;
|
|
35
|
+
* multiple sims open a picker, and the chosen one
|
|
36
|
+
* renders inline exactly like `/sim <name>`.
|
|
49
37
|
* - `/sim my <name>` Fuzzy-load by name within your own sims.
|
|
38
|
+
* Exact match auto-loads; ambiguous matches
|
|
39
|
+
* open the same picker.
|
|
50
40
|
*
|
|
51
41
|
* Tools (LLM-callable):
|
|
52
|
-
* - `simocracy_load_sim`
|
|
53
|
-
* - `simocracy_unload_sim`
|
|
54
|
-
* - `simocracy_chat`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* - `
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* - `simocracy_training_profile` Distill baseline + transcript into
|
|
62
|
-
* a TrainingProfile.
|
|
63
|
-
* - `simocracy_alignment_test` Score a profile against a baseline.
|
|
42
|
+
* - `simocracy_load_sim` Same as /sim <name>.
|
|
43
|
+
* - `simocracy_unload_sim` Same as /sim unload.
|
|
44
|
+
* - `simocracy_chat` One-shot chat with a sim via OpenRouter
|
|
45
|
+
* (does not change the active session
|
|
46
|
+
* persona).
|
|
47
|
+
* - `simocracy_update_sim` Write a new constitution and/or speaking
|
|
48
|
+
* style for the loaded sim to the user's
|
|
49
|
+
* PDS. Requires the user to be signed in
|
|
50
|
+
* via /sim login AND to own the sim.
|
|
64
51
|
*
|
|
65
52
|
* Note on /login: pi itself ships a built-in `/login` for Anthropic OAuth.
|
|
66
53
|
* To avoid the collision (and to make it explicit you're signing into
|
|
@@ -97,28 +84,19 @@ import {
|
|
|
97
84
|
} from "./png-to-ansi.ts";
|
|
98
85
|
import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
|
|
99
86
|
import { buildSimPrompt, type LoadedSim } from "./persona.ts";
|
|
100
|
-
import { runTrainCommand } from "./training/index.ts";
|
|
101
|
-
import {
|
|
102
|
-
runInterviewFlow,
|
|
103
|
-
snapshotInterviewTemplate,
|
|
104
|
-
deriveFromInterview,
|
|
105
|
-
} from "./interview.ts";
|
|
106
|
-
import { deriveProfile, printProfile } from "./training/profile.ts";
|
|
107
|
-
import {
|
|
108
|
-
loadTrainingLabState,
|
|
109
|
-
saveTrainingLabState,
|
|
110
|
-
} from "./training/storage.ts";
|
|
111
|
-
import { scoreAlignment, printAlignment } from "./training/alignment.ts";
|
|
112
|
-
import type {
|
|
113
|
-
AlignmentResult,
|
|
114
|
-
BaselineProposal,
|
|
115
|
-
BaselineVote,
|
|
116
|
-
InterviewTurn,
|
|
117
|
-
TrainingProfile,
|
|
118
|
-
Vote,
|
|
119
|
-
} from "./training/types.ts";
|
|
120
87
|
import { runLogin, runLogout, runWhoami } from "./auth/commands.ts";
|
|
121
88
|
import { readAuth } from "./auth/storage.ts";
|
|
89
|
+
import {
|
|
90
|
+
assertCanWriteToSim,
|
|
91
|
+
createAgents,
|
|
92
|
+
createStyle,
|
|
93
|
+
findRkeyForSim,
|
|
94
|
+
getAuthenticatedAgent,
|
|
95
|
+
NotSignedInError,
|
|
96
|
+
NotSimOwnerError,
|
|
97
|
+
updateAgents,
|
|
98
|
+
updateStyle,
|
|
99
|
+
} from "./writes.ts";
|
|
122
100
|
|
|
123
101
|
// ---------------------------------------------------------------------------
|
|
124
102
|
// State
|
|
@@ -303,94 +281,26 @@ const ChatToolParams = Type.Object({
|
|
|
303
281
|
|
|
304
282
|
const UnloadToolParams = Type.Object({});
|
|
305
283
|
|
|
306
|
-
const
|
|
307
|
-
|
|
284
|
+
const UpdateSimToolParams = Type.Object({
|
|
285
|
+
shortDescription: Type.Optional(
|
|
308
286
|
Type.String({
|
|
309
287
|
description:
|
|
310
|
-
"
|
|
288
|
+
"New short description for the sim's constitution. Max 300 chars; longer values will be truncated. Pass alongside `description` when rewriting the constitution; if you supply `description` without this, the existing short description (if any) is reused.",
|
|
289
|
+
maxLength: 300,
|
|
311
290
|
}),
|
|
312
291
|
),
|
|
313
|
-
|
|
292
|
+
description: Type.Optional(
|
|
314
293
|
Type.String({
|
|
315
294
|
description:
|
|
316
|
-
"
|
|
295
|
+
"New constitution body in markdown. Replaces the existing org.simocracy.agents record's `description`. Required when changing the constitution — a constitution with only a short description and no body is rejected.",
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
style: Type.Optional(
|
|
299
|
+
Type.String({
|
|
300
|
+
description:
|
|
301
|
+
"New speaking style description in markdown. Replaces the existing org.simocracy.style record's `description`. May be passed alone (style-only update) or together with `shortDescription` + `description` (constitution + style update).",
|
|
317
302
|
}),
|
|
318
303
|
),
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
const OpenAnswerSchema = Type.Object({
|
|
322
|
-
question: Type.String(),
|
|
323
|
-
answer: Type.String(),
|
|
324
|
-
});
|
|
325
|
-
const YesNoAnswerSchema = Type.Object({
|
|
326
|
-
statement: Type.String(),
|
|
327
|
-
answer: Type.Boolean(),
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
const DeriveConstitutionToolParams = Type.Object({
|
|
331
|
-
openAnswers: Type.Optional(Type.Array(OpenAnswerSchema)),
|
|
332
|
-
yesNoAnswers: Type.Optional(Type.Array(YesNoAnswerSchema)),
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const BaselineProposalSchema = Type.Object({
|
|
336
|
-
id: Type.String(),
|
|
337
|
-
title: Type.String(),
|
|
338
|
-
summary: Type.String(),
|
|
339
|
-
topic: Type.String(),
|
|
340
|
-
});
|
|
341
|
-
const BaselineVoteSchema = Type.Object({
|
|
342
|
-
proposalId: Type.String(),
|
|
343
|
-
vote: Type.Union([Type.Literal("yes"), Type.Literal("no"), Type.Literal("abstain")]),
|
|
344
|
-
importance: Type.Number(),
|
|
345
|
-
reasoning: Type.String(),
|
|
346
|
-
});
|
|
347
|
-
const InterviewTurnSchema = Type.Object({
|
|
348
|
-
role: Type.Union([Type.Literal("assistant"), Type.Literal("user")]),
|
|
349
|
-
content: Type.String(),
|
|
350
|
-
target: Type.Optional(Type.String()),
|
|
351
|
-
});
|
|
352
|
-
const IssuePrioritySchema = Type.Object({
|
|
353
|
-
issue: Type.String(),
|
|
354
|
-
stance: Type.String(),
|
|
355
|
-
importance: Type.Number(),
|
|
356
|
-
negotiability: Type.Number(),
|
|
357
|
-
confidence: Type.Number(),
|
|
358
|
-
});
|
|
359
|
-
const TrainingProfileSchema = Type.Object({
|
|
360
|
-
summary: Type.String(),
|
|
361
|
-
coreValues: Type.Array(Type.String()),
|
|
362
|
-
issuePriorities: Type.Array(IssuePrioritySchema),
|
|
363
|
-
redLines: Type.Array(Type.String()),
|
|
364
|
-
acceptableTradeoffs: Type.Array(Type.String()),
|
|
365
|
-
uncertaintyAreas: Type.Array(Type.String()),
|
|
366
|
-
representationRules: Type.Array(Type.String()),
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
const TrainingProfileToolParams = Type.Object({
|
|
370
|
-
simName: Type.String({ description: "The sim's display name." }),
|
|
371
|
-
existingConstitution: Type.Optional(Type.String()),
|
|
372
|
-
baselineVotes: Type.Array(BaselineVoteSchema),
|
|
373
|
-
baselineProposals: Type.Array(BaselineProposalSchema),
|
|
374
|
-
transcript: Type.Array(InterviewTurnSchema),
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
const AlignmentProposalSchema = Type.Object({
|
|
378
|
-
id: Type.String(),
|
|
379
|
-
title: Type.String(),
|
|
380
|
-
summary: Type.String(),
|
|
381
|
-
topic: Type.String(),
|
|
382
|
-
userVote: Type.Union([
|
|
383
|
-
Type.Literal("yes"),
|
|
384
|
-
Type.Literal("no"),
|
|
385
|
-
Type.Literal("abstain"),
|
|
386
|
-
]),
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
const AlignmentTestToolParams = Type.Object({
|
|
390
|
-
simName: Type.String({ description: "The sim's display name." }),
|
|
391
|
-
existingConstitution: Type.Optional(Type.String()),
|
|
392
|
-
profile: TrainingProfileSchema,
|
|
393
|
-
proposals: Type.Array(AlignmentProposalSchema),
|
|
394
304
|
});
|
|
395
305
|
|
|
396
306
|
export default async function simocracy(pi: ExtensionAPI) {
|
|
@@ -443,7 +353,7 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
443
353
|
// -------------------------------------------------------------------------
|
|
444
354
|
pi.registerCommand("sim", {
|
|
445
355
|
description:
|
|
446
|
-
"Simocracy: load
|
|
356
|
+
"Simocracy: load sims, edit your own sim's constitution/style, sign into ATProto. `/sim help` for the full list.",
|
|
447
357
|
handler: async (args, ctx) => {
|
|
448
358
|
const arg = args.trim();
|
|
449
359
|
if (!arg || arg === "help" || arg === "--help") {
|
|
@@ -453,24 +363,19 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
453
363
|
" /sim unload stop roleplaying\n" +
|
|
454
364
|
" /sim status show currently loaded sim\n" +
|
|
455
365
|
"\n" +
|
|
456
|
-
"
|
|
457
|
-
"
|
|
458
|
-
"
|
|
459
|
-
" /sim
|
|
460
|
-
" /sim train profile distill votes + chat → TrainingProfile\n" +
|
|
461
|
-
" /sim train alignment score sim against your baseline\n" +
|
|
462
|
-
" /sim train apply merge profile into constitution\n" +
|
|
463
|
-
" /sim train status|reset|feedback\n" +
|
|
366
|
+
"Refining your sim's constitution / speaking style:\n" +
|
|
367
|
+
" Just chat with pi about what you want to change — pi calls\n" +
|
|
368
|
+
" the simocracy_update_sim tool to write the new constitution or\n" +
|
|
369
|
+
" style to your PDS. Requires /sim login + sim ownership.\n" +
|
|
464
370
|
"\n" +
|
|
465
371
|
"Sign in with ATProto / Bluesky (not Anthropic — pi's built-in /login\n" +
|
|
466
|
-
"does that). Required before
|
|
372
|
+
"does that). Required before pi can update your sim:\n" +
|
|
467
373
|
" /sim login [handle] OAuth loopback flow (e.g. /sim login alice.bsky.social)\n" +
|
|
468
374
|
" /sim logout clear local session\n" +
|
|
469
375
|
" /sim whoami show signed-in handle/DID\n" +
|
|
470
376
|
"\n" +
|
|
471
377
|
"Browse your own sims (requires /sim login):\n" +
|
|
472
|
-
" /sim my
|
|
473
|
-
" /sim my <n> load sim #n from that list\n" +
|
|
378
|
+
" /sim my pick from sims you own (auto-loads if just one)\n" +
|
|
474
379
|
" /sim my <name> fuzzy-load by name within your sims",
|
|
475
380
|
"info",
|
|
476
381
|
);
|
|
@@ -519,34 +424,6 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
519
424
|
await postSimToChat(pi, ctx, loadedSim, /*reload=*/ false);
|
|
520
425
|
return;
|
|
521
426
|
}
|
|
522
|
-
if (arg === "interview" || arg.startsWith("interview ") || arg.startsWith("interview\t")) {
|
|
523
|
-
const rest = arg.slice("interview".length).trim();
|
|
524
|
-
// Strip recognised flags (--apply) and use the rest as a sim name.
|
|
525
|
-
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
526
|
-
const apply = tokens.includes("--apply");
|
|
527
|
-
const nameTokens = tokens.filter((t) => !t.startsWith("--"));
|
|
528
|
-
const simName = nameTokens.join(" ").trim();
|
|
529
|
-
if (simName && !loadedSim) {
|
|
530
|
-
const sim = await tryLoadFromQuery(simName);
|
|
531
|
-
if (!sim) {
|
|
532
|
-
ctx.ui.notify(`No sim found matching "${simName}".`, "error");
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
loadedSim = sim;
|
|
536
|
-
await postSimToChat(pi, ctx, sim, /*reload=*/ true);
|
|
537
|
-
}
|
|
538
|
-
if (!loadedSim) {
|
|
539
|
-
ctx.ui.notify("No sim loaded. Use `/sim <name>` first or pass a name to `/sim interview <name>`.", "error");
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
await runInterviewFlow(pi, ctx, loadedSim, { apply });
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
if (arg === "train" || arg.startsWith("train ") || arg.startsWith("train\t")) {
|
|
546
|
-
const rest = arg.slice("train".length).trim();
|
|
547
|
-
await runTrainCommand(ctx, rest, loadedSim);
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
427
|
await runLoadFlow(pi, ctx, arg);
|
|
551
428
|
},
|
|
552
429
|
});
|
|
@@ -673,202 +550,175 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
673
550
|
});
|
|
674
551
|
|
|
675
552
|
// -------------------------------------------------------------------------
|
|
676
|
-
// Tool:
|
|
553
|
+
// Tool: simocracy_update_sim — write a new constitution and/or speaking
|
|
554
|
+
// style for the loaded sim to the signed-in user's PDS.
|
|
555
|
+
//
|
|
556
|
+
// This is the *only* persona-edit surface this extension exposes. The
|
|
557
|
+
// older Interview Modal + Training Lab pipelines (`/sim interview`,
|
|
558
|
+
// `/sim train …`) were removed in favour of this single tool: pi (the
|
|
559
|
+
// coding agent) chats with the user about how to refine the sim, then
|
|
560
|
+
// calls this tool with the new short description / constitution body /
|
|
561
|
+
// speaking style. The model itself does the rewriting; we just persist
|
|
562
|
+
// the result and update the in-memory persona so the next reply uses it.
|
|
677
563
|
// -------------------------------------------------------------------------
|
|
678
564
|
pi.registerTool({
|
|
679
|
-
name: "
|
|
680
|
-
label: "
|
|
565
|
+
name: "simocracy_update_sim",
|
|
566
|
+
label: "Update Simocracy sim constitution / style",
|
|
681
567
|
description:
|
|
682
|
-
"
|
|
683
|
-
parameters:
|
|
684
|
-
async execute(_id, {
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (!loaded) throw new Error(`No sim found matching "${sim}".`);
|
|
690
|
-
loadedSim = loaded;
|
|
691
|
-
target = loaded;
|
|
692
|
-
await postSimToChat(pi, ctx, loaded, true);
|
|
693
|
-
}
|
|
694
|
-
if (!target) throw new Error("No sim loaded. Pass `sim` or call simocracy_load_sim first.");
|
|
695
|
-
const out = await runInterviewFlow(pi, ctx as ExtensionCommandContext, target, {
|
|
696
|
-
templateUri,
|
|
697
|
-
});
|
|
698
|
-
if (!out) {
|
|
699
|
-
return {
|
|
700
|
-
content: [{ type: "text" as const, text: "Interview cancelled." }],
|
|
701
|
-
details: { cancelled: true },
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
return {
|
|
705
|
-
content: [
|
|
706
|
-
{
|
|
707
|
-
type: "text" as const,
|
|
708
|
-
text: `Captured ${out.result.openAnswers.length} open answers and ${out.result.yesNoAnswers.length} value positions.`,
|
|
709
|
-
},
|
|
710
|
-
],
|
|
711
|
-
details: out.result,
|
|
712
|
-
};
|
|
568
|
+
"Update the currently loaded Simocracy sim's constitution (short description + markdown body) and/or speaking style on the user's ATProto PDS. Use this when the user asks to refine, rewrite, extend, or fix any part of the loaded sim's persona — e.g. 'add a red line about animal welfare to the constitution', 'rewrite the speaking style to drop the lenny faces', 'shorten the constitution and emphasise renewable energy'. Pass `description` (with optional `shortDescription`) to update the constitution; pass `style` to update the speaking style; pass any combination. Requires the user to be signed in via /sim login AND to own the loaded sim — the call will fail otherwise. The new persona takes effect on the very next reply, no reload needed.",
|
|
569
|
+
parameters: UpdateSimToolParams,
|
|
570
|
+
async execute(_id, { shortDescription, description, style }) {
|
|
571
|
+
if (!loadedSim) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
"No sim loaded. Call simocracy_load_sim first — the user must load the sim they want to edit.",
|
|
574
|
+
);
|
|
713
575
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
`Interview template "${snapshot.name}" — ${snapshot.questions.length} questions. ` +
|
|
722
|
-
`Run from a UI session to capture answers.`,
|
|
723
|
-
},
|
|
724
|
-
],
|
|
725
|
-
details: { template: snapshot, openAnswers: [], yesNoAnswers: [] },
|
|
726
|
-
};
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
// -------------------------------------------------------------------------
|
|
731
|
-
// Tool: simocracy_derive_constitution — turn answers into constitution+style.
|
|
732
|
-
// -------------------------------------------------------------------------
|
|
733
|
-
pi.registerTool({
|
|
734
|
-
name: "simocracy_derive_constitution",
|
|
735
|
-
label: "Derive Simocracy constitution",
|
|
736
|
-
description:
|
|
737
|
-
"Given an interview's open + yes/no answers, derive a sim constitution (short description + markdown body) and a speaking style. Returns plain markdown — no PDS write happens here.",
|
|
738
|
-
parameters: DeriveConstitutionToolParams,
|
|
739
|
-
async execute(_id, { openAnswers, yesNoAnswers }) {
|
|
740
|
-
const sim: LoadedSim = loadedSim ?? {
|
|
741
|
-
uri: "",
|
|
742
|
-
did: "",
|
|
743
|
-
rkey: "",
|
|
744
|
-
name: "Sim",
|
|
745
|
-
handle: null,
|
|
746
|
-
};
|
|
747
|
-
const derived = await deriveFromInterview(sim, {
|
|
748
|
-
openAnswers: openAnswers ?? [],
|
|
749
|
-
yesNoAnswers: yesNoAnswers ?? [],
|
|
750
|
-
});
|
|
751
|
-
if (!derived) {
|
|
752
|
-
throw new Error("Could not parse derive-from-interview model output.");
|
|
576
|
+
const wantsConstitution =
|
|
577
|
+
description !== undefined || shortDescription !== undefined;
|
|
578
|
+
const wantsStyle = style !== undefined;
|
|
579
|
+
if (!wantsConstitution && !wantsStyle) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
"Pass at least one of `description`, `shortDescription`, `style`. Empty calls are rejected.",
|
|
582
|
+
);
|
|
753
583
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
content: [{ type: "text" as const, text: summary }],
|
|
766
|
-
details: derived,
|
|
767
|
-
};
|
|
768
|
-
},
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
// -------------------------------------------------------------------------
|
|
772
|
-
// Tool: simocracy_training_profile — distill into a TrainingProfile.
|
|
773
|
-
// -------------------------------------------------------------------------
|
|
774
|
-
pi.registerTool({
|
|
775
|
-
name: "simocracy_training_profile",
|
|
776
|
-
label: "Distill Simocracy training profile",
|
|
777
|
-
description:
|
|
778
|
-
"Distill a baseline questionnaire + interview transcript into a structured TrainingProfile (summary, core values, issue priorities, red lines, etc.). Read-only — does not write to the sim.",
|
|
779
|
-
parameters: TrainingProfileToolParams,
|
|
780
|
-
async execute(_id, params) {
|
|
781
|
-
const sim: LoadedSim = loadedSim ?? {
|
|
782
|
-
uri: "",
|
|
783
|
-
did: "",
|
|
784
|
-
rkey: "",
|
|
785
|
-
name: params.simName,
|
|
786
|
-
handle: null,
|
|
787
|
-
description: params.existingConstitution,
|
|
788
|
-
};
|
|
789
|
-
const stateSnapshot = {
|
|
790
|
-
baselineVotes: params.baselineVotes as BaselineVote[],
|
|
791
|
-
interviewTurns: params.transcript as InterviewTurn[],
|
|
792
|
-
feedbackTurns: [] as never[],
|
|
793
|
-
profile: null as TrainingProfile | null,
|
|
794
|
-
alignment: null as AlignmentResult | null,
|
|
795
|
-
updatedAt: new Date().toISOString(),
|
|
796
|
-
};
|
|
797
|
-
const profile = await deriveProfile(
|
|
798
|
-
{ ...sim, description: params.existingConstitution ?? sim.description },
|
|
799
|
-
stateSnapshot,
|
|
800
|
-
params.baselineProposals as BaselineProposal[],
|
|
801
|
-
);
|
|
802
|
-
if (!profile) throw new Error("Could not parse training profile from model output.");
|
|
803
|
-
if (loadedSim && loadedSim.rkey) {
|
|
804
|
-
const persisted = loadTrainingLabState(loadedSim.rkey);
|
|
805
|
-
saveTrainingLabState(loadedSim.rkey, { ...persisted, profile, alignment: null });
|
|
584
|
+
// Owner + auth gate. The same precondition is re-checked at the
|
|
585
|
+
// XRPC call site in writes.ts (defense-in-depth) but we want a
|
|
586
|
+
// human-readable failure here before we touch the network.
|
|
587
|
+
let auth;
|
|
588
|
+
try {
|
|
589
|
+
auth = await assertCanWriteToSim(loadedSim, { action: "update" });
|
|
590
|
+
} catch (err) {
|
|
591
|
+
if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
|
|
592
|
+
throw new Error(err.message);
|
|
593
|
+
}
|
|
594
|
+
throw err;
|
|
806
595
|
}
|
|
596
|
+
let pdsAgent;
|
|
807
597
|
try {
|
|
808
|
-
|
|
809
|
-
} catch {
|
|
810
|
-
|
|
598
|
+
({ agent: pdsAgent } = await getAuthenticatedAgent());
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (err instanceof NotSignedInError) throw new Error(err.message);
|
|
601
|
+
throw new Error(`ATProto auth failed: ${(err as Error).message}`);
|
|
811
602
|
}
|
|
812
|
-
return {
|
|
813
|
-
content: [
|
|
814
|
-
{
|
|
815
|
-
type: "text" as const,
|
|
816
|
-
text: `Distilled profile: ${profile.coreValues.length} core values, ${profile.issuePriorities.length} issue priorities, ${profile.redLines.length} red lines.`,
|
|
817
|
-
},
|
|
818
|
-
],
|
|
819
|
-
details: profile,
|
|
820
|
-
};
|
|
821
|
-
},
|
|
822
|
-
});
|
|
823
603
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
description:
|
|
831
|
-
"Run the loaded sim's training profile against a list of proposals (each with the user's hidden vote) and return per-proposal match/mismatch + an overall match percentage. Calls the alignment prompt once per proposal at concurrency 4.",
|
|
832
|
-
parameters: AlignmentTestToolParams,
|
|
833
|
-
async execute(_id, params) {
|
|
834
|
-
const sim: LoadedSim = loadedSim ?? {
|
|
835
|
-
uri: "",
|
|
836
|
-
did: "",
|
|
837
|
-
rkey: "",
|
|
838
|
-
name: params.simName,
|
|
839
|
-
handle: null,
|
|
840
|
-
description: params.existingConstitution,
|
|
604
|
+
const updates: string[] = [];
|
|
605
|
+
const details: Record<string, unknown> = {
|
|
606
|
+
uri: loadedSim.uri,
|
|
607
|
+
did: loadedSim.did,
|
|
608
|
+
rkey: loadedSim.rkey,
|
|
609
|
+
name: loadedSim.name,
|
|
841
610
|
};
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
611
|
+
|
|
612
|
+
// Constitution update — org.simocracy.agents. Lexicon stores both
|
|
613
|
+
// shortDescription (≤300 chars) and description (full markdown). If
|
|
614
|
+
// the caller only passed one of those, fall back to the existing
|
|
615
|
+
// value on the loaded sim so we never end up with a half-empty
|
|
616
|
+
// record.
|
|
617
|
+
if (wantsConstitution) {
|
|
618
|
+
const finalShort =
|
|
619
|
+
shortDescription !== undefined
|
|
620
|
+
? shortDescription
|
|
621
|
+
: loadedSim.shortDescription ?? "";
|
|
622
|
+
const finalBody =
|
|
623
|
+
description !== undefined ? description : loadedSim.description ?? "";
|
|
624
|
+
if (!finalBody.trim()) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
"Cannot write an empty constitution body. Pass `description` with the new markdown body.",
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const existingRkey = await findRkeyForSim(
|
|
630
|
+
pdsAgent,
|
|
631
|
+
auth.did,
|
|
632
|
+
"org.simocracy.agents",
|
|
633
|
+
loadedSim.uri,
|
|
634
|
+
).catch(() => null);
|
|
635
|
+
try {
|
|
636
|
+
if (existingRkey) {
|
|
637
|
+
const res = await updateAgents({
|
|
638
|
+
agent: pdsAgent,
|
|
639
|
+
did: auth.did,
|
|
640
|
+
rkey: existingRkey,
|
|
641
|
+
simUri: loadedSim.uri,
|
|
642
|
+
simCid: "",
|
|
643
|
+
shortDescription: finalShort,
|
|
644
|
+
description: finalBody,
|
|
645
|
+
});
|
|
646
|
+
details.agentsUri = res.uri;
|
|
647
|
+
updates.push(`Updated constitution (org.simocracy.agents/${existingRkey}).`);
|
|
648
|
+
} else {
|
|
649
|
+
const res = await createAgents({
|
|
650
|
+
agent: pdsAgent,
|
|
651
|
+
did: auth.did,
|
|
652
|
+
simUri: loadedSim.uri,
|
|
653
|
+
simCid: "",
|
|
654
|
+
shortDescription: finalShort,
|
|
655
|
+
description: finalBody,
|
|
656
|
+
});
|
|
657
|
+
details.agentsUri = res.uri;
|
|
658
|
+
updates.push(`Created constitution (org.simocracy.agents/${res.rkey}).`);
|
|
659
|
+
}
|
|
660
|
+
} catch (err) {
|
|
661
|
+
throw new Error(`Constitution write failed: ${(err as Error).message}`);
|
|
662
|
+
}
|
|
663
|
+
// Mutate in-memory persona so the next `before_agent_start` event
|
|
664
|
+
// injects the new constitution without requiring an unload/reload.
|
|
665
|
+
loadedSim.shortDescription = finalShort;
|
|
666
|
+
loadedSim.description = finalBody;
|
|
856
667
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
668
|
+
|
|
669
|
+
// Speaking-style update — org.simocracy.style. Single field.
|
|
670
|
+
if (wantsStyle) {
|
|
671
|
+
const finalStyle = style ?? "";
|
|
672
|
+
if (!finalStyle.trim()) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
"Cannot write an empty speaking style. Pass `style` with the new markdown body.",
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
const existingRkey = await findRkeyForSim(
|
|
678
|
+
pdsAgent,
|
|
679
|
+
auth.did,
|
|
680
|
+
"org.simocracy.style",
|
|
681
|
+
loadedSim.uri,
|
|
682
|
+
).catch(() => null);
|
|
683
|
+
try {
|
|
684
|
+
if (existingRkey) {
|
|
685
|
+
const res = await updateStyle({
|
|
686
|
+
agent: pdsAgent,
|
|
687
|
+
did: auth.did,
|
|
688
|
+
rkey: existingRkey,
|
|
689
|
+
simUri: loadedSim.uri,
|
|
690
|
+
simCid: "",
|
|
691
|
+
description: finalStyle,
|
|
692
|
+
});
|
|
693
|
+
details.styleUri = res.uri;
|
|
694
|
+
updates.push(`Updated speaking style (org.simocracy.style/${existingRkey}).`);
|
|
695
|
+
} else {
|
|
696
|
+
const res = await createStyle({
|
|
697
|
+
agent: pdsAgent,
|
|
698
|
+
did: auth.did,
|
|
699
|
+
simUri: loadedSim.uri,
|
|
700
|
+
simCid: "",
|
|
701
|
+
description: finalStyle,
|
|
702
|
+
});
|
|
703
|
+
details.styleUri = res.uri;
|
|
704
|
+
updates.push(`Created speaking style (org.simocracy.style/${res.rkey}).`);
|
|
705
|
+
}
|
|
706
|
+
} catch (err) {
|
|
707
|
+
throw new Error(`Style write failed: ${(err as Error).message}`);
|
|
708
|
+
}
|
|
709
|
+
loadedSim.style = finalStyle;
|
|
861
710
|
}
|
|
711
|
+
|
|
712
|
+
const text = [
|
|
713
|
+
`Updated ${loadedSim.name} on ${auth.handle ? `@${auth.handle}` : auth.did}'s PDS:`,
|
|
714
|
+
...updates.map((u) => ` - ${u}`),
|
|
715
|
+
``,
|
|
716
|
+
`The new persona takes effect on your next reply.`,
|
|
717
|
+
].join("\n");
|
|
718
|
+
details.updates = updates;
|
|
862
719
|
return {
|
|
863
|
-
content: [
|
|
864
|
-
|
|
865
|
-
type: "text" as const,
|
|
866
|
-
text: `Alignment: ${alignment.matchedCount}/${alignment.totalCount} matched. Weak areas: ${
|
|
867
|
-
alignment.weakAreas.length ? alignment.weakAreas.join(", ") : "none"
|
|
868
|
-
}.`,
|
|
869
|
-
},
|
|
870
|
-
],
|
|
871
|
-
details: alignment,
|
|
720
|
+
content: [{ type: "text" as const, text }],
|
|
721
|
+
details,
|
|
872
722
|
};
|
|
873
723
|
},
|
|
874
724
|
});
|
|
@@ -880,19 +730,16 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
880
730
|
// ---------------------------------------------------------------------------
|
|
881
731
|
|
|
882
732
|
/**
|
|
883
|
-
* `/sim my [
|
|
884
|
-
*
|
|
885
|
-
*
|
|
733
|
+
* `/sim my [name]` — list and load sims owned by the currently
|
|
734
|
+
* signed-in DID. Mirrors the load UX of `/sim <name>` but pre-filtered
|
|
735
|
+
* to the user's own PDS:
|
|
886
736
|
*
|
|
887
|
-
* - bare `/sim my`
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
* we prompt with a select. AT-URIs are
|
|
894
|
-
* not allowed here — use `/sim <at-uri>`
|
|
895
|
-
* directly for that.
|
|
737
|
+
* - bare `/sim my` → if 1 sim: load it. If many: show a select
|
|
738
|
+
* picker; on pick, hydrate + render sprite
|
|
739
|
+
* inline exactly like `/sim <name>` does.
|
|
740
|
+
* - `/sim my <name>` → fuzzy-match within the user's sims. Exact
|
|
741
|
+
* name match loads directly; otherwise the
|
|
742
|
+
* ranked candidates go into a select picker.
|
|
896
743
|
*
|
|
897
744
|
* Reads the user's sims from their PDS via `com.atproto.repo.listRecords`
|
|
898
745
|
* (no DPoP needed for reads of the public collection), so this works
|
|
@@ -936,65 +783,52 @@ async function runMySimsCommand(
|
|
|
936
783
|
return;
|
|
937
784
|
}
|
|
938
785
|
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
lines.push(`📁 ${mySims.length} sim${mySims.length === 1 ? "" : "s"} owned by ${auth.handle ? `@${auth.handle}` : auth.did}:`);
|
|
946
|
-
lines.push("");
|
|
947
|
-
const maxNameLen = Math.min(
|
|
948
|
-
32,
|
|
949
|
-
mySims.reduce((m, s) => Math.max(m, s.sim.name.length), 0),
|
|
950
|
-
);
|
|
951
|
-
mySims.forEach((s, i) => {
|
|
952
|
-
const idx = String(i + 1).padStart(2, " ");
|
|
953
|
-
const name = s.sim.name.length > maxNameLen ? s.sim.name.slice(0, maxNameLen - 1) + "…" : s.sim.name.padEnd(maxNameLen, " ");
|
|
954
|
-
const created = (s.sim.createdAt || "").slice(0, 10);
|
|
955
|
-
lines.push(` ${idx}. ${name} ${created} at://…/${s.rkey}`);
|
|
956
|
-
});
|
|
957
|
-
lines.push("");
|
|
958
|
-
lines.push("Load one with `/sim my <n>` (e.g. `/sim my 1`) or `/sim my <name>`.");
|
|
959
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
// `/sim my <n>` — numeric index into the list above.
|
|
964
|
-
if (/^\d+$/.test(arg)) {
|
|
965
|
-
const n = parseInt(arg, 10);
|
|
966
|
-
if (n < 1 || n > mySims.length) {
|
|
786
|
+
// Narrow the candidate pool to fuzzy-matched sims when an arg was
|
|
787
|
+
// supplied; otherwise the full owned list is the candidate pool.
|
|
788
|
+
let candidates: SimMatch[];
|
|
789
|
+
if (arg) {
|
|
790
|
+
const matches = fuzzyMatchOwnedSims(mySims, arg);
|
|
791
|
+
if (matches.length === 0) {
|
|
967
792
|
ctx.ui.notify(
|
|
968
|
-
`No sim
|
|
793
|
+
`No sim matching "${arg}" in your ${mySims.length} sim${mySims.length === 1 ? "" : "s"}. Run /sim my (no args) to see them.`,
|
|
969
794
|
"error",
|
|
970
795
|
);
|
|
971
796
|
return;
|
|
972
797
|
}
|
|
973
|
-
|
|
974
|
-
|
|
798
|
+
// Exact name match — load straight away, same shortcut /sim <name>
|
|
799
|
+
// takes when the indexer returns one perfect hit.
|
|
800
|
+
if (matches.length === 1 || matches[0].score === 0) {
|
|
801
|
+
await loadAndPostMySim(pi, ctx, matches[0].sim);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
candidates = matches.map((m) => m.sim);
|
|
805
|
+
} else {
|
|
806
|
+
if (mySims.length === 1) {
|
|
807
|
+
// Only one owned sim — skip the picker, just load it.
|
|
808
|
+
await loadAndPostMySim(pi, ctx, mySims[0]);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
candidates = mySims;
|
|
975
812
|
}
|
|
976
813
|
|
|
977
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
814
|
+
// Picker — same shape as /sim <name>'s ambiguous-match prompt so the
|
|
815
|
+
// two flows feel identical. We show created-date as a secondary key
|
|
816
|
+
// since multiple sims can share a name within one repo.
|
|
817
|
+
const labels = candidates.map((s) => {
|
|
818
|
+
const created = (s.sim.createdAt || "").slice(0, 10);
|
|
819
|
+
const tail = created ? `${created} at://…/${s.rkey}` : `at://…/${s.rkey}`;
|
|
820
|
+
return `${s.sim.name} — ${tail}`;
|
|
821
|
+
});
|
|
822
|
+
const title = arg
|
|
823
|
+
? `Matches for "${arg}" in your ${mySims.length} sim${mySims.length === 1 ? "" : "s"}`
|
|
824
|
+
: `Your sims (${mySims.length})`;
|
|
825
|
+
const picked = await ctx.ui.select(title, labels);
|
|
826
|
+
if (!picked) {
|
|
827
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
984
828
|
return;
|
|
985
829
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
// Multiple non-exact matches — ask. Skip the prompt for an exact match.
|
|
989
|
-
const labels = matches.map((m) => `${m.sim.sim.name} — at://…/${m.sim.rkey}`);
|
|
990
|
-
const picked = await ctx.ui.select(`Multiple matches for "${arg}" in your sims`, labels);
|
|
991
|
-
if (!picked) {
|
|
992
|
-
ctx.ui.notify("Cancelled.", "info");
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
chosen = matches[labels.indexOf(picked)];
|
|
996
|
-
}
|
|
997
|
-
await loadAndPostMySim(pi, ctx, chosen.sim);
|
|
830
|
+
const chosen = candidates[labels.indexOf(picked)];
|
|
831
|
+
await loadAndPostMySim(pi, ctx, chosen);
|
|
998
832
|
}
|
|
999
833
|
|
|
1000
834
|
async function loadAndPostMySim(
|