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/src/index.ts CHANGED
@@ -1,19 +1,71 @@
1
1
  /**
2
- * pi-simocracy — load a Simocracy sim into your pi chat.
2
+ * pi-simocracy — load a Simocracy sim into your pi chat, train its
3
+ * constitution, and write it back to your ATProto PDS.
3
4
  *
4
- * - `/sim <name>` Load a sim by name (fuzzy search on the indexer).
5
- * Renders the sim's sprite as colored ANSI art directly
6
- * in the chat and pushes its constitution + speaking
7
- * style into the system prompt so pi roleplays as the
8
- * sim.
9
- * - `/sim unload` Drop the loaded sim and stop roleplaying.
10
- * - `/sim status` Show the currently loaded sim, if any.
5
+ * Sim commands:
6
+ * - `/sim <name>` Load a sim by name (fuzzy search on the indexer).
7
+ * Renders the sprite inline as colored ANSI art and
8
+ * pushes the sim's constitution + style into the
9
+ * system prompt so pi roleplays as the sim.
10
+ * - `/sim unload` Drop the loaded sim and stop roleplaying.
11
+ * - `/sim status` Show the currently loaded sim, if any.
12
+ *
13
+ * Constitution training (works on the loaded sim):
14
+ * - `/sim interview [name] [--apply]`
15
+ * Adaptive AI interview that derives a sim's constitution +
16
+ * speaking style from your answers. With `--apply` writes them
17
+ * back to your PDS (requires `/sim login`).
18
+ * - `/sim train baseline`
19
+ * Vote yes/no/abstain on sample proposals (5+ recommended).
20
+ * - `/sim train chat`
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.
34
+ *
35
+ * ATProto sign-in ("sign in with Bluesky / ATProto", NOT Anthropic):
36
+ * - `/sim login [handle]`
37
+ * Loopback OAuth flow — opens your PDS's authorize page in the
38
+ * browser, grants this CLI a DPoP-bound session, persists it to
39
+ * ~/.config/pi-simocracy/auth.json. Required before `--apply`
40
+ * writes records to your repo.
41
+ * - `/sim logout` Clear the local OAuth session.
42
+ * - `/sim whoami` Show the currently signed-in ATProto handle/DID.
43
+ *
44
+ * Browse your own sims (requires `/sim login`):
45
+ * - `/sim my` List the org.simocracy.sim records owned by
46
+ * the signed-in DID, newest first. Mirrors
47
+ * simocracy.org's My Sims page in terminal form.
48
+ * - `/sim my <n>` Load sim #n from that list (1-indexed).
49
+ * - `/sim my <name>` Fuzzy-load by name within your own sims.
11
50
  *
12
51
  * Tools (LLM-callable):
13
- * - `simocracy_load_sim` Same as /sim <name>.
14
- * - `simocracy_unload_sim` Same as /sim unload.
15
- * - `simocracy_chat` One-shot chat with a sim via OpenRouter (does
16
- * not change the active session persona).
52
+ * - `simocracy_load_sim` Same as /sim <name>.
53
+ * - `simocracy_unload_sim` Same as /sim unload.
54
+ * - `simocracy_chat` One-shot chat with a sim via
55
+ * OpenRouter (does not change the
56
+ * active session persona).
57
+ * - `simocracy_run_interview` Run /sim interview programmatically.
58
+ * - `simocracy_derive_constitution`
59
+ * Given interview answers, return the
60
+ * derived constitution + style.
61
+ * - `simocracy_training_profile` Distill baseline + transcript into
62
+ * a TrainingProfile.
63
+ * - `simocracy_alignment_test` Score a profile against a baseline.
64
+ *
65
+ * Note on /login: pi itself ships a built-in `/login` for Anthropic OAuth.
66
+ * To avoid the collision (and to make it explicit you're signing into
67
+ * ATProto, not Anthropic), all auth commands here are namespaced under
68
+ * `/sim`.
17
69
  */
18
70
 
19
71
  import type {
@@ -26,6 +78,7 @@ import { Type } from "typebox";
26
78
 
27
79
  import {
28
80
  searchSimsByName,
81
+ fetchSimsForDid,
29
82
  fetchAgentsForSim,
30
83
  fetchStyleForSim,
31
84
  fetchBlob,
@@ -35,26 +88,42 @@ import {
35
88
  type SimMatch,
36
89
  type StyleRecord,
37
90
  } from "./simocracy.ts";
38
- import { decodePng, renderRgbaToAnsi, cropRgba } from "./png-to-ansi.ts";
91
+ import {
92
+ decodePng,
93
+ renderRgbaToAnsi,
94
+ cropRgba,
95
+ detectPixelArtScale,
96
+ downscaleRgbaNearest,
97
+ } from "./png-to-ansi.ts";
39
98
  import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
99
+ 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
+ import { runLogin, runLogout, runWhoami } from "./auth/commands.ts";
121
+ import { readAuth } from "./auth/storage.ts";
40
122
 
41
123
  // ---------------------------------------------------------------------------
42
124
  // State
43
125
  // ---------------------------------------------------------------------------
44
126
 
45
- interface LoadedSim {
46
- uri: string;
47
- did: string;
48
- rkey: string;
49
- name: string;
50
- handle: string | null;
51
- shortDescription?: string;
52
- description?: string;
53
- style?: string;
54
- /** Pre-rendered colored ANSI art of the sim's sprite (4 walk frames). */
55
- spriteAnsi?: string;
56
- }
57
-
58
127
  let loadedSim: LoadedSim | null = null;
59
128
  /**
60
129
  * Name of the most recently unloaded sim, if any. Cleared after the next
@@ -119,7 +188,16 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
119
188
  try {
120
189
  const buf = await fetchBlob(sim.did, imageLink);
121
190
  const { width, height, data } = decodePng(buf);
122
- return renderRgbaToAnsi(data, width, height, {
191
+ // Old sims (pre-sprite-sheet) only have the avatar PNG, which
192
+ // simocracy.org renders by 4×-upscaling a native 32×32 sprite into
193
+ // a 128×128 image with nearest-neighbour. Detect that and downsample
194
+ // back to the original size so the inline render is the same
195
+ // ~13-line height as a sprite-sheet-equipped sim instead of
196
+ // ballooning to ~22 lines and pushing chat off-screen.
197
+ const scale = detectPixelArtScale(data, width, height, 8);
198
+ const native =
199
+ scale > 1 ? downscaleRgbaNearest(data, width, height, scale) : { data, width, height };
200
+ return renderRgbaToAnsi(native.data, native.width, native.height, {
123
201
  cropToContent: true,
124
202
  cropPad: 1,
125
203
  indent: 2,
@@ -132,41 +210,6 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
132
210
  return null;
133
211
  }
134
212
 
135
- function buildSimPrompt(sim: LoadedSim): string {
136
- const lines: string[] = [];
137
- lines.push(`# Simocracy roleplay`);
138
- lines.push(
139
- `You are now roleplaying as **${sim.name}**, a Simocracy sim — a simulated political agent in a decentralized governance simulation built on the AT Protocol.`,
140
- );
141
- lines.push(
142
- `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.`,
143
- );
144
- if (sim.handle) lines.push(`The sim's owner on ATProto is @${sim.handle} (${sim.did}).`);
145
- if (sim.shortDescription) {
146
- lines.push(``);
147
- lines.push(`## ${sim.name}'s identity`);
148
- lines.push(sim.shortDescription);
149
- }
150
- if (sim.description) {
151
- lines.push(``);
152
- lines.push(`## ${sim.name}'s constitution`);
153
- lines.push(sim.description);
154
- }
155
- if (sim.style) {
156
- lines.push(``);
157
- lines.push(`## ${sim.name}'s speaking style`);
158
- lines.push(sim.style);
159
- }
160
- lines.push(``);
161
- lines.push(
162
- `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.`,
163
- );
164
- lines.push(
165
- `Keep replies conversational unless the user explicitly asks for code or a long answer.`,
166
- );
167
- return lines.join("\n");
168
- }
169
-
170
213
  async function loadSimByName(query: string): Promise<{
171
214
  matches: SimMatch[];
172
215
  loaded?: LoadedSim;
@@ -260,6 +303,96 @@ const ChatToolParams = Type.Object({
260
303
 
261
304
  const UnloadToolParams = Type.Object({});
262
305
 
306
+ const RunInterviewToolParams = Type.Object({
307
+ sim: Type.Optional(
308
+ Type.String({
309
+ description:
310
+ "Sim name or AT-URI to interview. Defaults to the currently loaded sim if omitted.",
311
+ }),
312
+ ),
313
+ templateUri: Type.Optional(
314
+ Type.String({
315
+ description:
316
+ "AT-URI of an `org.simocracy.interviewTemplate` to use. Skips the picker.",
317
+ }),
318
+ ),
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
+ });
395
+
263
396
  export default async function simocracy(pi: ExtensionAPI) {
264
397
  // -------------------------------------------------------------------------
265
398
  // System prompt injection — every turn the loaded sim's persona is appended.
@@ -301,20 +434,70 @@ export default async function simocracy(pi: ExtensionAPI) {
301
434
 
302
435
  // -------------------------------------------------------------------------
303
436
  // Slash command: /sim
437
+ //
438
+ // All extension commands are namespaced under /sim to avoid colliding
439
+ // with pi's built-in slash commands (notably `/login` for Anthropic
440
+ // OAuth and `/logout`). That namespacing also makes it unambiguous to
441
+ // users that `/sim login` signs them into their ATProto / Bluesky
442
+ // account, NOT into Anthropic.
304
443
  // -------------------------------------------------------------------------
305
444
  pi.registerCommand("sim", {
306
- description: "Load a Simocracy sim into your chat (or `/sim unload`, `/sim status`).",
445
+ description:
446
+ "Simocracy: load/train sims, sign into ATProto. `/sim help` for the full list.",
307
447
  handler: async (args, ctx) => {
308
448
  const arg = args.trim();
309
449
  if (!arg || arg === "help" || arg === "--help") {
310
450
  ctx.ui.notify(
311
- "Usage: /sim <name> load a sim (e.g. /sim mr meow)\n" +
312
- " /sim unload stop roleplaying\n" +
313
- " /sim status show currently loaded sim",
451
+ "Sim:\n" +
452
+ " /sim <name> load a sim (e.g. /sim mr meow)\n" +
453
+ " /sim unload stop roleplaying\n" +
454
+ " /sim status show currently loaded sim\n" +
455
+ "\n" +
456
+ "Constitution training (operates on the loaded sim):\n" +
457
+ " /sim interview [name] adaptive interview → derive constitution\n" +
458
+ " /sim train baseline vote on sample proposals\n" +
459
+ " /sim train chat conversational training round\n" +
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" +
464
+ "\n" +
465
+ "Sign in with ATProto / Bluesky (not Anthropic — pi's built-in /login\n" +
466
+ "does that). Required before `--apply` writes to your PDS:\n" +
467
+ " /sim login [handle] OAuth loopback flow (e.g. /sim login alice.bsky.social)\n" +
468
+ " /sim logout clear local session\n" +
469
+ " /sim whoami show signed-in handle/DID\n" +
470
+ "\n" +
471
+ "Browse your own sims (requires /sim login):\n" +
472
+ " /sim my list sims you own on your PDS\n" +
473
+ " /sim my <n> load sim #n from that list\n" +
474
+ " /sim my <name> fuzzy-load by name within your sims",
314
475
  "info",
315
476
  );
316
477
  return;
317
478
  }
479
+ // ATProto auth subcommands — must come BEFORE the sim-name
480
+ // fallthrough (`runLoadFlow`) so we don't accidentally treat
481
+ // "login" as a sim name to load from the indexer.
482
+ if (arg === "login" || arg.startsWith("login ") || arg.startsWith("login\t")) {
483
+ const rest = arg.slice("login".length).trim();
484
+ await runLogin(ctx, rest);
485
+ return;
486
+ }
487
+ if (arg === "logout") {
488
+ await runLogout(ctx);
489
+ return;
490
+ }
491
+ if (arg === "whoami") {
492
+ await runWhoami(ctx);
493
+ return;
494
+ }
495
+ if (arg === "my" || arg === "mine" || arg.startsWith("my ") || arg.startsWith("my\t") || arg.startsWith("mine ") || arg.startsWith("mine\t")) {
496
+ const headLen = arg.startsWith("mine") ? 4 : 2;
497
+ const rest = arg.slice(headLen).trim();
498
+ await runMySimsCommand(pi, ctx, rest);
499
+ return;
500
+ }
318
501
  if (arg === "unload" || arg === "clear") {
319
502
  if (!loadedSim) {
320
503
  ctx.ui.notify("No sim loaded.", "info");
@@ -336,10 +519,56 @@ export default async function simocracy(pi: ExtensionAPI) {
336
519
  await postSimToChat(pi, ctx, loadedSim, /*reload=*/ false);
337
520
  return;
338
521
  }
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
+ }
339
550
  await runLoadFlow(pi, ctx, arg);
340
551
  },
341
552
  });
342
553
 
554
+ // -------------------------------------------------------------------------
555
+ // (Removed) top-level /login, /logout, /whoami slash commands.
556
+ //
557
+ // These collided with pi's own built-in `/login` (Anthropic OAuth) and
558
+ // `/logout`, which made pi emit "Skipping in autocomplete" warnings on
559
+ // every boot and silently degraded discoverability of these handlers.
560
+ // The auth flow now lives under `/sim login`, `/sim logout`,
561
+ // `/sim whoami` — no collision, and the namespacing makes it explicit
562
+ // to users that they're signing into their ATProto / Bluesky account,
563
+ // not Anthropic. See the dispatcher in the `/sim` registerCommand
564
+ // above.
565
+ //
566
+ // The `runLogin` / `runLogout` / `runWhoami` helpers in src/auth/
567
+ // commands.ts are unchanged — only the slash-command surface moved.
568
+ // -------------------------------------------------------------------------
569
+ // (no top-level registration — the auth helpers `runLogin`, `runLogout`,
570
+ // `runWhoami` are dispatched from inside the `/sim` handler above.)
571
+
343
572
  // -------------------------------------------------------------------------
344
573
  // Tool: simocracy_load_sim
345
574
  // -------------------------------------------------------------------------
@@ -442,12 +671,380 @@ export default async function simocracy(pi: ExtensionAPI) {
442
671
  };
443
672
  },
444
673
  });
674
+
675
+ // -------------------------------------------------------------------------
676
+ // Tool: simocracy_run_interview — run the interview questionnaire.
677
+ // -------------------------------------------------------------------------
678
+ pi.registerTool({
679
+ name: "simocracy_run_interview",
680
+ label: "Run Simocracy interview",
681
+ description:
682
+ "Run the Simocracy interview questionnaire on the loaded sim and return the captured open + yes/no answers. With UI, drives the user through the questions interactively. Without UI, returns the structure of the chosen template as a planning aid (no answers).",
683
+ parameters: RunInterviewToolParams,
684
+ async execute(_id, { sim, templateUri }, _signal, _onUpdate, ctx) {
685
+ if (ctx.hasUI) {
686
+ let target: LoadedSim | null = loadedSim;
687
+ if (sim) {
688
+ const loaded = await tryLoadFromQuery(sim);
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
+ };
713
+ }
714
+ // No UI: return the template snapshot as a planning aid.
715
+ const snapshot = await snapshotInterviewTemplate(templateUri);
716
+ return {
717
+ content: [
718
+ {
719
+ type: "text" as const,
720
+ text:
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.");
753
+ }
754
+ const summary = [
755
+ `Short description:`,
756
+ derived.constitution.shortDescription,
757
+ ``,
758
+ `Constitution:`,
759
+ derived.constitution.description,
760
+ ``,
761
+ `Speaking style:`,
762
+ derived.style.description,
763
+ ].join("\n");
764
+ return {
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 });
806
+ }
807
+ try {
808
+ printProfile(profile);
809
+ } catch {
810
+ /* best effort */
811
+ }
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
+
824
+ // -------------------------------------------------------------------------
825
+ // Tool: simocracy_alignment_test — score the sim against baseline votes.
826
+ // -------------------------------------------------------------------------
827
+ pi.registerTool({
828
+ name: "simocracy_alignment_test",
829
+ label: "Run Simocracy alignment test",
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,
841
+ };
842
+ const aligned = (params.proposals as Array<BaselineProposal & { userVote: Vote }>).map(
843
+ (p) => ({
844
+ proposal: { id: p.id, title: p.title, summary: p.summary, topic: p.topic },
845
+ userVote: p.userVote,
846
+ }),
847
+ );
848
+ const alignment = await scoreAlignment(
849
+ { ...sim, description: params.existingConstitution ?? sim.description },
850
+ params.profile as TrainingProfile,
851
+ aligned,
852
+ );
853
+ if (loadedSim && loadedSim.rkey) {
854
+ const persisted = loadTrainingLabState(loadedSim.rkey);
855
+ saveTrainingLabState(loadedSim.rkey, { ...persisted, alignment });
856
+ }
857
+ try {
858
+ printAlignment(alignment, aligned);
859
+ } catch {
860
+ /* not in UI mode — printing is best-effort */
861
+ }
862
+ 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,
872
+ };
873
+ },
874
+ });
445
875
  }
446
876
 
877
+
447
878
  // ---------------------------------------------------------------------------
448
879
  // Slash-command flow
449
880
  // ---------------------------------------------------------------------------
450
881
 
882
+ /**
883
+ * `/sim my [arg]` — list (and optionally load) sims owned by the
884
+ * currently signed-in DID. Three calling conventions, picked by
885
+ * cheapest-first:
886
+ *
887
+ * - bare `/sim my` → print a numbered list, no load
888
+ * - `/sim my <n>` (digits) → load the n-th sim from the list
889
+ * - `/sim my <text>` → fuzzy-match by name within the user's
890
+ * sims (uses the same scoring as the
891
+ * global indexer search), load the best.
892
+ * If two of the user's sims share a name
893
+ * we prompt with a select. AT-URIs are
894
+ * not allowed here — use `/sim <at-uri>`
895
+ * directly for that.
896
+ *
897
+ * Reads the user's sims from their PDS via `com.atproto.repo.listRecords`
898
+ * (no DPoP needed for reads of the public collection), so this works
899
+ * even if the OAuth session has expired — it only needs the DID, which
900
+ * the auth.json keeps after `lastLogin` is stale.
901
+ */
902
+ async function runMySimsCommand(
903
+ pi: ExtensionAPI,
904
+ ctx: ExtensionCommandContext,
905
+ arg: string,
906
+ ): Promise<void> {
907
+ const auth = readAuth();
908
+ if (!auth) {
909
+ ctx.ui.notify(
910
+ "Not signed into ATProto. Run `/sim login <handle>` first (e.g. `/sim login alice.bsky.social`) so /sim my knows which DID's repo to list.",
911
+ "error",
912
+ );
913
+ return;
914
+ }
915
+
916
+ ctx.ui.notify(`Listing sims owned by ${auth.handle ? `@${auth.handle}` : auth.did}\u2026`, "info");
917
+
918
+ let mySims: SimMatch[];
919
+ try {
920
+ mySims = await fetchSimsForDid(auth.did);
921
+ } catch (err) {
922
+ ctx.ui.notify(
923
+ `Could not list sims from your PDS: ${(err as Error).message}. Is the DID document still resolvable?`,
924
+ "error",
925
+ );
926
+ return;
927
+ }
928
+
929
+ if (mySims.length === 0) {
930
+ ctx.ui.notify(
931
+ auth.handle
932
+ ? `@${auth.handle} doesn't own any sims yet. Visit https://simocracy.org/my-sims to create one, then come back and try /sim my again.`
933
+ : `No sims found on this PDS. Create one at https://simocracy.org/my-sims and try again.`,
934
+ "info",
935
+ );
936
+ return;
937
+ }
938
+
939
+ // Bare `/sim my` — just list. Indented so the chat treats this as
940
+ // a content message (notify is best for short feedback; for the
941
+ // list we want fixed-width alignment so we go through the registered
942
+ // message renderer that the load flow already uses for sim summaries).
943
+ if (!arg) {
944
+ const lines: string[] = [];
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) {
967
+ ctx.ui.notify(
968
+ `No sim #${n} — you have ${mySims.length}. Run /sim my (no args) to see the list.`,
969
+ "error",
970
+ );
971
+ return;
972
+ }
973
+ await loadAndPostMySim(pi, ctx, mySims[n - 1]);
974
+ return;
975
+ }
976
+
977
+ // `/sim my <name>` — fuzzy-match within the user's own sims.
978
+ const matches = fuzzyMatchOwnedSims(mySims, arg);
979
+ if (matches.length === 0) {
980
+ ctx.ui.notify(
981
+ `No sim matching "${arg}" in your ${mySims.length} sim${mySims.length === 1 ? "" : "s"}. Run /sim my (no args) to see them.`,
982
+ "error",
983
+ );
984
+ return;
985
+ }
986
+ let chosen = matches[0];
987
+ if (matches.length > 1 && matches[0].score > 0) {
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);
998
+ }
999
+
1000
+ async function loadAndPostMySim(
1001
+ pi: ExtensionAPI,
1002
+ ctx: ExtensionCommandContext,
1003
+ match: SimMatch,
1004
+ ): Promise<void> {
1005
+ ctx.ui.notify(`Loading ${match.sim.name}…`, "info");
1006
+ let sim: LoadedSim;
1007
+ try {
1008
+ sim = await hydrateLoadedSim(match);
1009
+ } catch (err) {
1010
+ ctx.ui.notify(`Failed to load sim: ${(err as Error).message}`, "error");
1011
+ return;
1012
+ }
1013
+ loadedSim = sim;
1014
+ await postSimToChat(pi, ctx, sim, true);
1015
+ }
1016
+
1017
+ /**
1018
+ * Score user-owned sims against a query string. Returns matches sorted
1019
+ * best-first. Score 0 = exact name match (prompt-suppressing), higher =
1020
+ * worse. Mirrors the heuristic the indexer search uses but constrained
1021
+ * to the already-fetched list, so this is purely client-side and no
1022
+ * extra HTTP calls are issued.
1023
+ */
1024
+ function fuzzyMatchOwnedSims(
1025
+ sims: SimMatch[],
1026
+ query: string,
1027
+ ): Array<{ sim: SimMatch; score: number }> {
1028
+ const q = query.toLowerCase().trim();
1029
+ const out: Array<{ sim: SimMatch; score: number }> = [];
1030
+ for (const sim of sims) {
1031
+ const name = sim.sim.name.toLowerCase().trim();
1032
+ let score = Number.POSITIVE_INFINITY;
1033
+ if (name === q) score = 0;
1034
+ else if (name.replace(/\s+/g, "") === q.replace(/\s+/g, "")) score = 1;
1035
+ else if (name.startsWith(q)) score = 2;
1036
+ else if (name.includes(q)) score = 3 + (name.length - q.length);
1037
+ else {
1038
+ const tokens = q.split(/\s+/).filter(Boolean);
1039
+ const matched = tokens.filter((t) => name.includes(t)).length;
1040
+ if (matched > 0) score = 100 - matched;
1041
+ }
1042
+ if (Number.isFinite(score)) out.push({ sim, score });
1043
+ }
1044
+ out.sort((a, b) => a.score - b.score);
1045
+ return out;
1046
+ }
1047
+
451
1048
  async function runLoadFlow(
452
1049
  pi: ExtensionAPI,
453
1050
  ctx: ExtensionCommandContext,