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/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * pi-simocracy — load a Simocracy sim into your pi chat, train its
3
- * constitution, and write it back to your ATProto PDS.
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
- * 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.
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 `--apply`
40
- * writes records to your repo.
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` 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).
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` 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.
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 RunInterviewToolParams = Type.Object({
307
- sim: Type.Optional(
284
+ const UpdateSimToolParams = Type.Object({
285
+ shortDescription: Type.Optional(
308
286
  Type.String({
309
287
  description:
310
- "Sim name or AT-URI to interview. Defaults to the currently loaded sim if omitted.",
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
- templateUri: Type.Optional(
292
+ description: Type.Optional(
314
293
  Type.String({
315
294
  description:
316
- "AT-URI of an `org.simocracy.interviewTemplate` to use. Skips the picker.",
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/train sims, sign into ATProto. `/sim help` for the full list.",
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
- "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" +
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 `--apply` writes to your PDS:\n" +
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 list sims you own on your PDS\n" +
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: simocracy_run_interviewrun the interview questionnaire.
553
+ // Tool: simocracy_update_simwrite 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: "simocracy_run_interview",
680
- label: "Run Simocracy interview",
565
+ name: "simocracy_update_sim",
566
+ label: "Update Simocracy sim constitution / style",
681
567
  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
- };
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
- // 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.");
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
- 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 });
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
- printProfile(profile);
809
- } catch {
810
- /* best effort */
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
- // 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,
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
- 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 });
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
- try {
858
- printAlignment(alignment, aligned);
859
- } catch {
860
- /* not in UI mode — printing is best-effort */
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 [arg]` — list (and optionally load) sims owned by the
884
- * currently signed-in DID. Three calling conventions, picked by
885
- * cheapest-first:
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` 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.
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
- // 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) {
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 #${n} you have ${mySims.length}. Run /sim my (no args) to see the list.`,
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
- await loadAndPostMySim(pi, ctx, mySims[n - 1]);
974
- return;
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
- // `/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
- );
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
- 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);
830
+ const chosen = candidates[labels.indexOf(picked)];
831
+ await loadAndPostMySim(pi, ctx, chosen);
998
832
  }
999
833
 
1000
834
  async function loadAndPostMySim(