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/README.md +89 -30
- package/package.json +2 -1
- package/src/auth/commands.ts +4 -4
- package/src/index.ts +361 -444
- package/src/openrouter.ts +0 -16
- package/src/png-to-ansi.ts +79 -0
- package/src/simocracy.ts +24 -90
- package/src/webp-to-rgba.ts +70 -0
- package/src/writes.ts +117 -53
- package/src/interview.ts +0 -516
- package/src/training/alignment.ts +0 -259
- package/src/training/apply.ts +0 -271
- package/src/training/baseline.ts +0 -159
- package/src/training/chat.ts +0 -131
- package/src/training/feedback.ts +0 -81
- package/src/training/index.ts +0 -142
- package/src/training/profile.ts +0 -229
- package/src/training/prompt-helpers.ts +0 -70
- package/src/training/prompts.ts +0 -131
- package/src/training/question-set.ts +0 -134
- package/src/training/storage.ts +0 -81
- package/src/training/types.ts +0 -121
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-simocracy — load a Simocracy sim into your pi chat,
|
|
3
|
-
* constitution
|
|
2
|
+
* pi-simocracy — load a Simocracy sim into your pi chat, refine its
|
|
3
|
+
* constitution + speaking style by chatting with pi, and write the
|
|
4
|
+
* result back to your ATProto PDS.
|
|
4
5
|
*
|
|
5
6
|
* Sim commands:
|
|
6
7
|
* - `/sim <name>` Load a sim by name (fuzzy search on the indexer).
|
|
@@ -10,57 +11,43 @@
|
|
|
10
11
|
* - `/sim unload` Drop the loaded sim and stop roleplaying.
|
|
11
12
|
* - `/sim status` Show the currently loaded sim, if any.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Conversational training round — the sim asks targeted
|
|
22
|
-
* questions about gaps it sees in the baseline votes.
|
|
23
|
-
* - `/sim train profile`
|
|
24
|
-
* Distill the baseline + chat transcript into a structured
|
|
25
|
-
* TrainingProfile (priorities, red lines, tradeoffs).
|
|
26
|
-
* - `/sim train alignment`
|
|
27
|
-
* Score the sim against your hidden baseline and report match%.
|
|
28
|
-
* - `/sim train apply [--apply]`
|
|
29
|
-
* Merge the profile into the constitution. Without `--apply`
|
|
30
|
-
* copies the merged markdown to clipboard. With `--apply`
|
|
31
|
-
* writes to your PDS (requires `/sim login`).
|
|
32
|
-
* - `/sim train feedback`, `status`, `reset`
|
|
33
|
-
* Free-form feedback chat, status, and clear local state.
|
|
14
|
+
* Editing your sim's constitution / speaking style:
|
|
15
|
+
* There is no `/sim train` or `/sim interview` slash flow. Instead,
|
|
16
|
+
* load a sim you own and tell pi how you want the persona to change
|
|
17
|
+
* ("add a red line about animal welfare", "make the speaking style
|
|
18
|
+
* punchier and drop the lenny faces", etc.). Pi rewrites the
|
|
19
|
+
* constitution and/or speaking style and calls the
|
|
20
|
+
* `simocracy_update_sim` tool to write the result to your PDS.
|
|
21
|
+
* Requires `/sim login` and ownership of the loaded sim.
|
|
34
22
|
*
|
|
35
23
|
* ATProto sign-in ("sign in with Bluesky / ATProto", NOT Anthropic):
|
|
36
24
|
* - `/sim login [handle]`
|
|
37
25
|
* Loopback OAuth flow — opens your PDS's authorize page in the
|
|
38
26
|
* browser, grants this CLI a DPoP-bound session, persists it to
|
|
39
|
-
* ~/.config/pi-simocracy/auth.json. Required before
|
|
40
|
-
*
|
|
27
|
+
* ~/.config/pi-simocracy/auth.json. Required before pi can
|
|
28
|
+
* update your sim's constitution / style.
|
|
41
29
|
* - `/sim logout` Clear the local OAuth session.
|
|
42
30
|
* - `/sim whoami` Show the currently signed-in ATProto handle/DID.
|
|
43
31
|
*
|
|
44
32
|
* Browse your own sims (requires `/sim login`):
|
|
45
|
-
* - `/sim my`
|
|
46
|
-
* the signed-in DID
|
|
47
|
-
*
|
|
48
|
-
*
|
|
33
|
+
* - `/sim my` Pick from the org.simocracy.sim records owned
|
|
34
|
+
* by the signed-in DID. Single sim auto-loads;
|
|
35
|
+
* multiple sims open a picker, and the chosen one
|
|
36
|
+
* renders inline exactly like `/sim <name>`.
|
|
49
37
|
* - `/sim my <name>` Fuzzy-load by name within your own sims.
|
|
38
|
+
* Exact match auto-loads; ambiguous matches
|
|
39
|
+
* open the same picker.
|
|
50
40
|
*
|
|
51
41
|
* Tools (LLM-callable):
|
|
52
|
-
* - `simocracy_load_sim`
|
|
53
|
-
* - `simocracy_unload_sim`
|
|
54
|
-
* - `simocracy_chat`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* - `
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* - `simocracy_training_profile` Distill baseline + transcript into
|
|
62
|
-
* a TrainingProfile.
|
|
63
|
-
* - `simocracy_alignment_test` Score a profile against a baseline.
|
|
42
|
+
* - `simocracy_load_sim` Same as /sim <name>.
|
|
43
|
+
* - `simocracy_unload_sim` Same as /sim unload.
|
|
44
|
+
* - `simocracy_chat` One-shot chat with a sim via OpenRouter
|
|
45
|
+
* (does not change the active session
|
|
46
|
+
* persona).
|
|
47
|
+
* - `simocracy_update_sim` Write a new constitution and/or speaking
|
|
48
|
+
* style for the loaded sim to the user's
|
|
49
|
+
* PDS. Requires the user to be signed in
|
|
50
|
+
* via /sim login AND to own the sim.
|
|
64
51
|
*
|
|
65
52
|
* Note on /login: pi itself ships a built-in `/login` for Anthropic OAuth.
|
|
66
53
|
* To avoid the collision (and to make it explicit you're signing into
|
|
@@ -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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
-
|
|
199
|
-
scale > 1
|
|
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
|
|
307
|
-
|
|
361
|
+
const UpdateSimToolParams = Type.Object({
|
|
362
|
+
shortDescription: Type.Optional(
|
|
308
363
|
Type.String({
|
|
309
364
|
description:
|
|
310
|
-
"
|
|
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
|
-
|
|
369
|
+
description: Type.Optional(
|
|
314
370
|
Type.String({
|
|
315
371
|
description:
|
|
316
|
-
"
|
|
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
|
|
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
|
-
"
|
|
457
|
-
"
|
|
458
|
-
"
|
|
459
|
-
" /sim
|
|
460
|
-
" /sim train profile distill votes + chat → TrainingProfile\n" +
|
|
461
|
-
" /sim train alignment score sim against your baseline\n" +
|
|
462
|
-
" /sim train apply merge profile into constitution\n" +
|
|
463
|
-
" /sim train status|reset|feedback\n" +
|
|
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
|
|
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
|
|
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:
|
|
630
|
+
// Tool: simocracy_update_sim — write 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: "
|
|
680
|
-
label: "
|
|
642
|
+
name: "simocracy_update_sim",
|
|
643
|
+
label: "Update Simocracy sim constitution / style",
|
|
681
644
|
description:
|
|
682
|
-
"
|
|
683
|
-
parameters:
|
|
684
|
-
async execute(_id, {
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (!loaded) throw new Error(`No sim found matching "${sim}".`);
|
|
690
|
-
loadedSim = loaded;
|
|
691
|
-
target = loaded;
|
|
692
|
-
await postSimToChat(pi, ctx, loaded, true);
|
|
693
|
-
}
|
|
694
|
-
if (!target) throw new Error("No sim loaded. Pass `sim` or call simocracy_load_sim first.");
|
|
695
|
-
const out = await runInterviewFlow(pi, ctx as ExtensionCommandContext, target, {
|
|
696
|
-
templateUri,
|
|
697
|
-
});
|
|
698
|
-
if (!out) {
|
|
699
|
-
return {
|
|
700
|
-
content: [{ type: "text" as const, text: "Interview cancelled." }],
|
|
701
|
-
details: { cancelled: true },
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
return {
|
|
705
|
-
content: [
|
|
706
|
-
{
|
|
707
|
-
type: "text" as const,
|
|
708
|
-
text: `Captured ${out.result.openAnswers.length} open answers and ${out.result.yesNoAnswers.length} value positions.`,
|
|
709
|
-
},
|
|
710
|
-
],
|
|
711
|
-
details: out.result,
|
|
712
|
-
};
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
`Interview template "${snapshot.name}" — ${snapshot.questions.length} questions. ` +
|
|
722
|
-
`Run from a UI session to capture answers.`,
|
|
723
|
-
},
|
|
724
|
-
],
|
|
725
|
-
details: { template: snapshot, openAnswers: [], yesNoAnswers: [] },
|
|
726
|
-
};
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
// -------------------------------------------------------------------------
|
|
731
|
-
// Tool: simocracy_derive_constitution — turn answers into constitution+style.
|
|
732
|
-
// -------------------------------------------------------------------------
|
|
733
|
-
pi.registerTool({
|
|
734
|
-
name: "simocracy_derive_constitution",
|
|
735
|
-
label: "Derive Simocracy constitution",
|
|
736
|
-
description:
|
|
737
|
-
"Given an interview's open + yes/no answers, derive a sim constitution (short description + markdown body) and a speaking style. Returns plain markdown — no PDS write happens here.",
|
|
738
|
-
parameters: DeriveConstitutionToolParams,
|
|
739
|
-
async execute(_id, { openAnswers, yesNoAnswers }) {
|
|
740
|
-
const sim: LoadedSim = loadedSim ?? {
|
|
741
|
-
uri: "",
|
|
742
|
-
did: "",
|
|
743
|
-
rkey: "",
|
|
744
|
-
name: "Sim",
|
|
745
|
-
handle: null,
|
|
746
|
-
};
|
|
747
|
-
const derived = await deriveFromInterview(sim, {
|
|
748
|
-
openAnswers: openAnswers ?? [],
|
|
749
|
-
yesNoAnswers: yesNoAnswers ?? [],
|
|
750
|
-
});
|
|
751
|
-
if (!derived) {
|
|
752
|
-
throw new Error("Could not parse derive-from-interview model output.");
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
content: [{ type: "text" as const, text: summary }],
|
|
766
|
-
details: derived,
|
|
767
|
-
};
|
|
768
|
-
},
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
// -------------------------------------------------------------------------
|
|
772
|
-
// Tool: simocracy_training_profile — distill into a TrainingProfile.
|
|
773
|
-
// -------------------------------------------------------------------------
|
|
774
|
-
pi.registerTool({
|
|
775
|
-
name: "simocracy_training_profile",
|
|
776
|
-
label: "Distill Simocracy training profile",
|
|
777
|
-
description:
|
|
778
|
-
"Distill a baseline questionnaire + interview transcript into a structured TrainingProfile (summary, core values, issue priorities, red lines, etc.). Read-only — does not write to the sim.",
|
|
779
|
-
parameters: TrainingProfileToolParams,
|
|
780
|
-
async execute(_id, params) {
|
|
781
|
-
const sim: LoadedSim = loadedSim ?? {
|
|
782
|
-
uri: "",
|
|
783
|
-
did: "",
|
|
784
|
-
rkey: "",
|
|
785
|
-
name: params.simName,
|
|
786
|
-
handle: null,
|
|
787
|
-
description: params.existingConstitution,
|
|
788
|
-
};
|
|
789
|
-
const stateSnapshot = {
|
|
790
|
-
baselineVotes: params.baselineVotes as BaselineVote[],
|
|
791
|
-
interviewTurns: params.transcript as InterviewTurn[],
|
|
792
|
-
feedbackTurns: [] as never[],
|
|
793
|
-
profile: null as TrainingProfile | null,
|
|
794
|
-
alignment: null as AlignmentResult | null,
|
|
795
|
-
updatedAt: new Date().toISOString(),
|
|
796
|
-
};
|
|
797
|
-
const profile = await deriveProfile(
|
|
798
|
-
{ ...sim, description: params.existingConstitution ?? sim.description },
|
|
799
|
-
stateSnapshot,
|
|
800
|
-
params.baselineProposals as BaselineProposal[],
|
|
801
|
-
);
|
|
802
|
-
if (!profile) throw new Error("Could not parse training profile from model output.");
|
|
803
|
-
if (loadedSim && loadedSim.rkey) {
|
|
804
|
-
const persisted = loadTrainingLabState(loadedSim.rkey);
|
|
805
|
-
saveTrainingLabState(loadedSim.rkey, { ...persisted, profile, alignment: null });
|
|
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
|
-
|
|
809
|
-
} catch {
|
|
810
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
description:
|
|
831
|
-
"Run the loaded sim's training profile against a list of proposals (each with the user's hidden vote) and return per-proposal match/mismatch + an overall match percentage. Calls the alignment prompt once per proposal at concurrency 4.",
|
|
832
|
-
parameters: AlignmentTestToolParams,
|
|
833
|
-
async execute(_id, params) {
|
|
834
|
-
const sim: LoadedSim = loadedSim ?? {
|
|
835
|
-
uri: "",
|
|
836
|
-
did: "",
|
|
837
|
-
rkey: "",
|
|
838
|
-
name: params.simName,
|
|
839
|
-
handle: null,
|
|
840
|
-
description: params.existingConstitution,
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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 [
|
|
884
|
-
*
|
|
885
|
-
*
|
|
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`
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
* we prompt with a select. AT-URIs are
|
|
894
|
-
* not allowed here — use `/sim <at-uri>`
|
|
895
|
-
* directly for that.
|
|
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
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
lines.push(`📁 ${mySims.length} sim${mySims.length === 1 ? "" : "s"} owned by ${auth.handle ? `@${auth.handle}` : auth.did}:`);
|
|
946
|
-
lines.push("");
|
|
947
|
-
const maxNameLen = Math.min(
|
|
948
|
-
32,
|
|
949
|
-
mySims.reduce((m, s) => Math.max(m, s.sim.name.length), 0),
|
|
950
|
-
);
|
|
951
|
-
mySims.forEach((s, i) => {
|
|
952
|
-
const idx = String(i + 1).padStart(2, " ");
|
|
953
|
-
const name = s.sim.name.length > maxNameLen ? s.sim.name.slice(0, maxNameLen - 1) + "…" : s.sim.name.padEnd(maxNameLen, " ");
|
|
954
|
-
const created = (s.sim.createdAt || "").slice(0, 10);
|
|
955
|
-
lines.push(` ${idx}. ${name} ${created} at://…/${s.rkey}`);
|
|
956
|
-
});
|
|
957
|
-
lines.push("");
|
|
958
|
-
lines.push("Load one with `/sim my <n>` (e.g. `/sim my 1`) or `/sim my <name>`.");
|
|
959
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
// `/sim my <n>` — numeric index into the list above.
|
|
964
|
-
if (/^\d+$/.test(arg)) {
|
|
965
|
-
const n = parseInt(arg, 10);
|
|
966
|
-
if (n < 1 || n > mySims.length) {
|
|
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
|
|
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
|
-
|
|
974
|
-
|
|
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
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
// Multiple non-exact matches — ask. Skip the prompt for an exact match.
|
|
989
|
-
const labels = matches.map((m) => `${m.sim.sim.name} — at://…/${m.sim.rkey}`);
|
|
990
|
-
const picked = await ctx.ui.select(`Multiple matches for "${arg}" in your sims`, labels);
|
|
991
|
-
if (!picked) {
|
|
992
|
-
ctx.ui.notify("Cancelled.", "info");
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
chosen = matches[labels.indexOf(picked)];
|
|
996
|
-
}
|
|
997
|
-
await loadAndPostMySim(pi, ctx, chosen.sim);
|
|
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
|
-
|
|
1112
|
-
|
|
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
|
};
|