pi-simocracy 0.2.0 → 0.4.1

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
@@ -94,31 +81,24 @@ import {
94
81
  cropRgba,
95
82
  detectPixelArtScale,
96
83
  downscaleRgbaNearest,
84
+ boxDownscaleRgba,
97
85
  } from "./png-to-ansi.ts";
86
+ import { decodeWebp } from "./webp-to-rgba.ts";
98
87
  import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
99
88
  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
89
  import { runLogin, runLogout, runWhoami } from "./auth/commands.ts";
121
90
  import { readAuth } from "./auth/storage.ts";
91
+ import {
92
+ assertCanWriteToSim,
93
+ createAgents,
94
+ createStyle,
95
+ findRkeyForSim,
96
+ getAuthenticatedAgent,
97
+ NotSignedInError,
98
+ NotSimOwnerError,
99
+ updateAgents,
100
+ updateStyle,
101
+ } from "./writes.ts";
122
102
 
123
103
  // ---------------------------------------------------------------------------
124
104
  // State
@@ -146,19 +126,43 @@ function blobLink(ref: unknown): string | null {
146
126
  }
147
127
 
148
128
  /**
149
- * Render a sim's sprite at its native 32×32 size as colored ANSI
150
- * half-block art 16 cells tall, 32 cells wide. Compact enough to fit
151
- * comfortably in a terminal alongside the loaded-sim message.
129
+ * Maximum long-edge of a non-pixel-art avatar before we box-downscale it.
130
+ * Picked so codex pet idle frames render at roughly the same on-screen
131
+ * height as a 32×32 pipoya sprite (~17 terminal lines) instead of
132
+ * ballooning to 60+ lines and pushing the rest of chat off-screen.
133
+ */
134
+ const NON_PIXEL_ART_TARGET_LONG_EDGE = 40;
135
+
136
+ /**
137
+ * Render a sim's avatar as colored ANSI half-block art, sized to fit
138
+ * comfortably alongside the loaded-sim message in a typical terminal.
139
+ *
140
+ * Branches on `spriteKind` (lexicon discriminator):
141
+ *
142
+ * pipoya — fetch the 4×4 walk-sheet PNG, crop the front-facing
143
+ * walk-1 frame (32×32, row 0 col 0), render at native
144
+ * resolution. ~16 lines tall.
145
+ *
146
+ * codexPet — prefer the 8×9 atlas (`petSheet`, 1536×1872, 192×208
147
+ * cells) when it's PNG: crop the idle cell (row 0 col 0)
148
+ * and box-downscale to ~32 wide. The atlas is usually WebP
149
+ * (the OpenAI hatch-pet skill emits WebP and `pngjs`
150
+ * doesn't speak WebP), so we fall through to the rendered
151
+ * `image` thumbnail — a PNG that the simocracy.org client
152
+ * generates at 128×128 from the idle frame. That gets
153
+ * box-downscaled to a comparable terminal size.
152
154
  *
153
- * Pulls the front-facing walk1 frame (row 0, col 0) from the 128×128
154
- * sprite-sheet blob. Falls back to the static avatar PNG if no sheet
155
- * is published for this sim.
155
+ * absent — treated as legacy 'pipoya' for back-compat.
156
156
  */
157
157
  async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
158
+ const spriteKind = sim.sim.spriteKind ?? "pipoya";
158
159
  const spriteLink = blobLink(sim.sim.sprite?.ref);
160
+ const petSheetLink = blobLink(sim.sim.petSheet?.ref);
161
+ const petSheetMime = sim.sim.petSheet?.mimeType;
159
162
  const imageLink = blobLink(sim.sim.image?.ref);
160
163
 
161
- if (spriteLink) {
164
+ // Pipoya 4×4 walk sheet — the legacy/default path.
165
+ if (spriteKind !== "codexPet" && spriteLink) {
162
166
  try {
163
167
  const buf = await fetchBlob(sim.did, spriteLink);
164
168
  const { width, height, data } = decodePng(buf);
@@ -180,23 +184,74 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
180
184
  alphaThreshold: 16,
181
185
  });
182
186
  } catch {
183
- /* fall through to avatar */
187
+ /* fall through to image fallback */
184
188
  }
185
189
  }
186
190
 
191
+ // Codex pet atlas — the canonical asset for codex pets. Sheets are
192
+ // 1536×1872 with 192×208 cells laid out 8 cols × 9 rows; row 0 col 0
193
+ // is the idle frame. Both PNG and WebP are valid in the lexicon (the
194
+ // hatch-pet skill emits WebP, the dropzone preserves PNG when the
195
+ // user drops a PNG sheet) so we pick the right decoder by mimeType.
196
+ // We crop the idle cell first thing and box-downscale to ~32 wide,
197
+ // so the inline render is similar in height to a pipoya sprite
198
+ // (~17 lines).
199
+ if (spriteKind === "codexPet" && petSheetLink) {
200
+ try {
201
+ const buf = await fetchBlob(sim.did, petSheetLink);
202
+ const { width, height, data } =
203
+ petSheetMime === "image/webp" ? await decodeWebp(buf) : decodePng(buf);
204
+ const CELL_W = 192;
205
+ const CELL_H = 208;
206
+ if (width >= CELL_W && height >= CELL_H) {
207
+ const cell = cropRgba(data, width, height, 0, 0, CELL_W, CELL_H);
208
+ const targetW = 32;
209
+ const targetH = Math.round((CELL_H / CELL_W) * targetW); // ~35
210
+ const scaled = boxDownscaleRgba(cell, CELL_W, CELL_H, targetW, targetH);
211
+ return renderRgbaToAnsi(scaled.data, scaled.width, scaled.height, {
212
+ cropToContent: true,
213
+ cropPad: 1,
214
+ indent: 2,
215
+ alphaThreshold: 16,
216
+ });
217
+ }
218
+ } catch {
219
+ /* fall through to image fallback */
220
+ }
221
+ }
222
+
223
+ // Image fallback — used when a sim has no walk sheet (legacy pipoya
224
+ // sims that pre-date `org.simocracy.sim.sprite`) or when the codex
225
+ // pet atlas decode failed for any reason. The simocracy.org client
226
+ // always generates a 128×128 PNG idle thumbnail and uploads it as
227
+ // `image` for codex pets, so even when this path runs the right pose
228
+ // shows up.
187
229
  if (imageLink) {
188
230
  try {
189
231
  const buf = await fetchBlob(sim.did, imageLink);
190
232
  const { width, height, data } = decodePng(buf);
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.
233
+ // Old pipoya sims publish a 128×128 PNG that's a 4×-upscaled 32×32
234
+ // pixel-art sprite. Detect and undo that with nearest-neighbour
235
+ // downsampling.
197
236
  const scale = detectPixelArtScale(data, width, height, 8);
198
- const native =
199
- scale > 1 ? downscaleRgbaNearest(data, width, height, scale) : { data, width, height };
237
+ let native: { data: Buffer; width: number; height: number } =
238
+ scale > 1
239
+ ? downscaleRgbaNearest(data, width, height, scale)
240
+ : { data, width, height };
241
+ // Non-pixel-art images (codex pet thumbnails, avatars from other
242
+ // pipelines) come through at full resolution — box-downscale them
243
+ // so the ANSI render fits in a typical terminal row budget.
244
+ const longEdge = Math.max(native.width, native.height);
245
+ if (scale === 1 && longEdge > NON_PIXEL_ART_TARGET_LONG_EDGE) {
246
+ const k = NON_PIXEL_ART_TARGET_LONG_EDGE / longEdge;
247
+ native = boxDownscaleRgba(
248
+ native.data,
249
+ native.width,
250
+ native.height,
251
+ Math.max(1, Math.round(native.width * k)),
252
+ Math.max(1, Math.round(native.height * k)),
253
+ );
254
+ }
200
255
  return renderRgbaToAnsi(native.data, native.width, native.height, {
201
256
  cropToContent: true,
202
257
  cropPad: 1,
@@ -303,94 +358,26 @@ const ChatToolParams = Type.Object({
303
358
 
304
359
  const UnloadToolParams = Type.Object({});
305
360
 
306
- const RunInterviewToolParams = Type.Object({
307
- sim: Type.Optional(
361
+ const UpdateSimToolParams = Type.Object({
362
+ shortDescription: Type.Optional(
308
363
  Type.String({
309
364
  description:
310
- "Sim name or AT-URI to interview. Defaults to the currently loaded sim if omitted.",
365
+ "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.",
366
+ maxLength: 300,
311
367
  }),
312
368
  ),
313
- templateUri: Type.Optional(
369
+ description: Type.Optional(
314
370
  Type.String({
315
371
  description:
316
- "AT-URI of an `org.simocracy.interviewTemplate` to use. Skips the picker.",
372
+ "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.",
373
+ }),
374
+ ),
375
+ style: Type.Optional(
376
+ Type.String({
377
+ description:
378
+ "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
379
  }),
318
380
  ),
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
381
  });
395
382
 
396
383
  export default async function simocracy(pi: ExtensionAPI) {
@@ -443,7 +430,7 @@ export default async function simocracy(pi: ExtensionAPI) {
443
430
  // -------------------------------------------------------------------------
444
431
  pi.registerCommand("sim", {
445
432
  description:
446
- "Simocracy: load/train sims, sign into ATProto. `/sim help` for the full list.",
433
+ "Simocracy: load sims, edit your own sim's constitution/style, sign into ATProto. `/sim help` for the full list.",
447
434
  handler: async (args, ctx) => {
448
435
  const arg = args.trim();
449
436
  if (!arg || arg === "help" || arg === "--help") {
@@ -453,24 +440,19 @@ export default async function simocracy(pi: ExtensionAPI) {
453
440
  " /sim unload stop roleplaying\n" +
454
441
  " /sim status show currently loaded sim\n" +
455
442
  "\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" +
443
+ "Refining your sim's constitution / speaking style:\n" +
444
+ " Just chat with pi about what you want to change — pi calls\n" +
445
+ " the simocracy_update_sim tool to write the new constitution or\n" +
446
+ " style to your PDS. Requires /sim login + sim ownership.\n" +
464
447
  "\n" +
465
448
  "Sign in with ATProto / Bluesky (not Anthropic — pi's built-in /login\n" +
466
- "does that). Required before `--apply` writes to your PDS:\n" +
449
+ "does that). Required before pi can update your sim:\n" +
467
450
  " /sim login [handle] OAuth loopback flow (e.g. /sim login alice.bsky.social)\n" +
468
451
  " /sim logout clear local session\n" +
469
452
  " /sim whoami show signed-in handle/DID\n" +
470
453
  "\n" +
471
454
  "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" +
455
+ " /sim my pick from sims you own (auto-loads if just one)\n" +
474
456
  " /sim my <name> fuzzy-load by name within your sims",
475
457
  "info",
476
458
  );
@@ -519,34 +501,6 @@ export default async function simocracy(pi: ExtensionAPI) {
519
501
  await postSimToChat(pi, ctx, loadedSim, /*reload=*/ false);
520
502
  return;
521
503
  }
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
504
  await runLoadFlow(pi, ctx, arg);
551
505
  },
552
506
  });
@@ -673,202 +627,175 @@ export default async function simocracy(pi: ExtensionAPI) {
673
627
  });
674
628
 
675
629
  // -------------------------------------------------------------------------
676
- // Tool: simocracy_run_interviewrun the interview questionnaire.
630
+ // Tool: simocracy_update_simwrite a new constitution and/or speaking
631
+ // style for the loaded sim to the signed-in user's PDS.
632
+ //
633
+ // This is the *only* persona-edit surface this extension exposes. The
634
+ // older Interview Modal + Training Lab pipelines (`/sim interview`,
635
+ // `/sim train …`) were removed in favour of this single tool: pi (the
636
+ // coding agent) chats with the user about how to refine the sim, then
637
+ // calls this tool with the new short description / constitution body /
638
+ // speaking style. The model itself does the rewriting; we just persist
639
+ // the result and update the in-memory persona so the next reply uses it.
677
640
  // -------------------------------------------------------------------------
678
641
  pi.registerTool({
679
- name: "simocracy_run_interview",
680
- label: "Run Simocracy interview",
642
+ name: "simocracy_update_sim",
643
+ label: "Update Simocracy sim constitution / style",
681
644
  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
- };
645
+ "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.",
646
+ parameters: UpdateSimToolParams,
647
+ async execute(_id, { shortDescription, description, style }) {
648
+ if (!loadedSim) {
649
+ throw new Error(
650
+ "No sim loaded. Call simocracy_load_sim first — the user must load the sim they want to edit.",
651
+ );
713
652
  }
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.");
653
+ const wantsConstitution =
654
+ description !== undefined || shortDescription !== undefined;
655
+ const wantsStyle = style !== undefined;
656
+ if (!wantsConstitution && !wantsStyle) {
657
+ throw new Error(
658
+ "Pass at least one of `description`, `shortDescription`, `style`. Empty calls are rejected.",
659
+ );
753
660
  }
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 });
661
+ // Owner + auth gate. The same precondition is re-checked at the
662
+ // XRPC call site in writes.ts (defense-in-depth) but we want a
663
+ // human-readable failure here before we touch the network.
664
+ let auth;
665
+ try {
666
+ auth = await assertCanWriteToSim(loadedSim, { action: "update" });
667
+ } catch (err) {
668
+ if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
669
+ throw new Error(err.message);
670
+ }
671
+ throw err;
806
672
  }
673
+ let pdsAgent;
807
674
  try {
808
- printProfile(profile);
809
- } catch {
810
- /* best effort */
675
+ ({ agent: pdsAgent } = await getAuthenticatedAgent());
676
+ } catch (err) {
677
+ if (err instanceof NotSignedInError) throw new Error(err.message);
678
+ throw new Error(`ATProto auth failed: ${(err as Error).message}`);
811
679
  }
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
680
 
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,
681
+ const updates: string[] = [];
682
+ const details: Record<string, unknown> = {
683
+ uri: loadedSim.uri,
684
+ did: loadedSim.did,
685
+ rkey: loadedSim.rkey,
686
+ name: loadedSim.name,
841
687
  };
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 });
688
+
689
+ // Constitution update — org.simocracy.agents. Lexicon stores both
690
+ // shortDescription (≤300 chars) and description (full markdown). If
691
+ // the caller only passed one of those, fall back to the existing
692
+ // value on the loaded sim so we never end up with a half-empty
693
+ // record.
694
+ if (wantsConstitution) {
695
+ const finalShort =
696
+ shortDescription !== undefined
697
+ ? shortDescription
698
+ : loadedSim.shortDescription ?? "";
699
+ const finalBody =
700
+ description !== undefined ? description : loadedSim.description ?? "";
701
+ if (!finalBody.trim()) {
702
+ throw new Error(
703
+ "Cannot write an empty constitution body. Pass `description` with the new markdown body.",
704
+ );
705
+ }
706
+ const existingRkey = await findRkeyForSim(
707
+ pdsAgent,
708
+ auth.did,
709
+ "org.simocracy.agents",
710
+ loadedSim.uri,
711
+ ).catch(() => null);
712
+ try {
713
+ if (existingRkey) {
714
+ const res = await updateAgents({
715
+ agent: pdsAgent,
716
+ did: auth.did,
717
+ rkey: existingRkey,
718
+ simUri: loadedSim.uri,
719
+ simCid: "",
720
+ shortDescription: finalShort,
721
+ description: finalBody,
722
+ });
723
+ details.agentsUri = res.uri;
724
+ updates.push(`Updated constitution (org.simocracy.agents/${existingRkey}).`);
725
+ } else {
726
+ const res = await createAgents({
727
+ agent: pdsAgent,
728
+ did: auth.did,
729
+ simUri: loadedSim.uri,
730
+ simCid: "",
731
+ shortDescription: finalShort,
732
+ description: finalBody,
733
+ });
734
+ details.agentsUri = res.uri;
735
+ updates.push(`Created constitution (org.simocracy.agents/${res.rkey}).`);
736
+ }
737
+ } catch (err) {
738
+ throw new Error(`Constitution write failed: ${(err as Error).message}`);
739
+ }
740
+ // Mutate in-memory persona so the next `before_agent_start` event
741
+ // injects the new constitution without requiring an unload/reload.
742
+ loadedSim.shortDescription = finalShort;
743
+ loadedSim.description = finalBody;
856
744
  }
857
- try {
858
- printAlignment(alignment, aligned);
859
- } catch {
860
- /* not in UI mode — printing is best-effort */
745
+
746
+ // Speaking-style update — org.simocracy.style. Single field.
747
+ if (wantsStyle) {
748
+ const finalStyle = style ?? "";
749
+ if (!finalStyle.trim()) {
750
+ throw new Error(
751
+ "Cannot write an empty speaking style. Pass `style` with the new markdown body.",
752
+ );
753
+ }
754
+ const existingRkey = await findRkeyForSim(
755
+ pdsAgent,
756
+ auth.did,
757
+ "org.simocracy.style",
758
+ loadedSim.uri,
759
+ ).catch(() => null);
760
+ try {
761
+ if (existingRkey) {
762
+ const res = await updateStyle({
763
+ agent: pdsAgent,
764
+ did: auth.did,
765
+ rkey: existingRkey,
766
+ simUri: loadedSim.uri,
767
+ simCid: "",
768
+ description: finalStyle,
769
+ });
770
+ details.styleUri = res.uri;
771
+ updates.push(`Updated speaking style (org.simocracy.style/${existingRkey}).`);
772
+ } else {
773
+ const res = await createStyle({
774
+ agent: pdsAgent,
775
+ did: auth.did,
776
+ simUri: loadedSim.uri,
777
+ simCid: "",
778
+ description: finalStyle,
779
+ });
780
+ details.styleUri = res.uri;
781
+ updates.push(`Created speaking style (org.simocracy.style/${res.rkey}).`);
782
+ }
783
+ } catch (err) {
784
+ throw new Error(`Style write failed: ${(err as Error).message}`);
785
+ }
786
+ loadedSim.style = finalStyle;
861
787
  }
788
+
789
+ const text = [
790
+ `Updated ${loadedSim.name} on ${auth.handle ? `@${auth.handle}` : auth.did}'s PDS:`,
791
+ ...updates.map((u) => ` - ${u}`),
792
+ ``,
793
+ `The new persona takes effect on your next reply.`,
794
+ ].join("\n");
795
+ details.updates = updates;
862
796
  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,
797
+ content: [{ type: "text" as const, text }],
798
+ details,
872
799
  };
873
800
  },
874
801
  });
@@ -880,19 +807,16 @@ export default async function simocracy(pi: ExtensionAPI) {
880
807
  // ---------------------------------------------------------------------------
881
808
 
882
809
  /**
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:
810
+ * `/sim my [name]` — list and load sims owned by the currently
811
+ * signed-in DID. Mirrors the load UX of `/sim <name>` but pre-filtered
812
+ * to the user's own PDS:
886
813
  *
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.
814
+ * - bare `/sim my` if 1 sim: load it. If many: show a select
815
+ * picker; on pick, hydrate + render sprite
816
+ * inline exactly like `/sim <name>` does.
817
+ * - `/sim my <name>` → fuzzy-match within the user's sims. Exact
818
+ * name match loads directly; otherwise the
819
+ * ranked candidates go into a select picker.
896
820
  *
897
821
  * Reads the user's sims from their PDS via `com.atproto.repo.listRecords`
898
822
  * (no DPoP needed for reads of the public collection), so this works
@@ -936,65 +860,52 @@ async function runMySimsCommand(
936
860
  return;
937
861
  }
938
862
 
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) {
863
+ // Narrow the candidate pool to fuzzy-matched sims when an arg was
864
+ // supplied; otherwise the full owned list is the candidate pool.
865
+ let candidates: SimMatch[];
866
+ if (arg) {
867
+ const matches = fuzzyMatchOwnedSims(mySims, arg);
868
+ if (matches.length === 0) {
967
869
  ctx.ui.notify(
968
- `No sim #${n} you have ${mySims.length}. Run /sim my (no args) to see the list.`,
870
+ `No sim matching "${arg}" in your ${mySims.length} sim${mySims.length === 1 ? "" : "s"}. Run /sim my (no args) to see them.`,
969
871
  "error",
970
872
  );
971
873
  return;
972
874
  }
973
- await loadAndPostMySim(pi, ctx, mySims[n - 1]);
974
- return;
875
+ // Exact name match — load straight away, same shortcut /sim <name>
876
+ // takes when the indexer returns one perfect hit.
877
+ if (matches.length === 1 || matches[0].score === 0) {
878
+ await loadAndPostMySim(pi, ctx, matches[0].sim);
879
+ return;
880
+ }
881
+ candidates = matches.map((m) => m.sim);
882
+ } else {
883
+ if (mySims.length === 1) {
884
+ // Only one owned sim — skip the picker, just load it.
885
+ await loadAndPostMySim(pi, ctx, mySims[0]);
886
+ return;
887
+ }
888
+ candidates = mySims;
975
889
  }
976
890
 
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
- );
891
+ // Picker — same shape as /sim <name>'s ambiguous-match prompt so the
892
+ // two flows feel identical. We show created-date as a secondary key
893
+ // since multiple sims can share a name within one repo.
894
+ const labels = candidates.map((s) => {
895
+ const created = (s.sim.createdAt || "").slice(0, 10);
896
+ const tail = created ? `${created} at://…/${s.rkey}` : `at://…/${s.rkey}`;
897
+ return `${s.sim.name} — ${tail}`;
898
+ });
899
+ const title = arg
900
+ ? `Matches for "${arg}" in your ${mySims.length} sim${mySims.length === 1 ? "" : "s"}`
901
+ : `Your sims (${mySims.length})`;
902
+ const picked = await ctx.ui.select(title, labels);
903
+ if (!picked) {
904
+ ctx.ui.notify("Cancelled.", "info");
984
905
  return;
985
906
  }
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);
907
+ const chosen = candidates[labels.indexOf(picked)];
908
+ await loadAndPostMySim(pi, ctx, chosen);
998
909
  }
999
910
 
1000
911
  async function loadAndPostMySim(
@@ -1108,8 +1019,11 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
1108
1019
  const { getRecordFromPds } = await import("./simocracy.ts");
1109
1020
  const sim = await getRecordFromPds<{
1110
1021
  name: string;
1111
- image?: { ref: unknown };
1112
- sprite?: { ref: unknown };
1022
+ spriteKind?: "pipoya" | "codexPet";
1023
+ image?: { ref: unknown; mimeType: string; size: number };
1024
+ sprite?: { ref: unknown; mimeType: string; size: number };
1025
+ petSheet?: { ref: unknown; mimeType: string; size: number };
1026
+ petManifest?: { id?: string; displayName?: string; description?: string };
1113
1027
  $type?: string;
1114
1028
  }>(did, "org.simocracy.sim", rkey);
1115
1029
  const match: SimMatch = {
@@ -1120,9 +1034,12 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
1120
1034
  sim: {
1121
1035
  $type: "org.simocracy.sim",
1122
1036
  name: sim.name,
1037
+ spriteKind: sim.spriteKind,
1123
1038
  settings: { selectedOptions: {} },
1124
1039
  image: sim.image as never,
1125
1040
  sprite: sim.sprite as never,
1041
+ petSheet: sim.petSheet as never,
1042
+ petManifest: sim.petManifest,
1126
1043
  createdAt: "",
1127
1044
  },
1128
1045
  };