pi-simocracy 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/auth/callback-server.ts +93 -0
- package/src/auth/commands.ts +159 -0
- package/src/auth/oauth.ts +55 -0
- package/src/auth/pages.ts +100 -0
- package/src/auth/storage.ts +121 -0
- package/src/index.ts +646 -64
- package/src/interview.ts +516 -0
- package/src/openrouter.ts +16 -0
- package/src/persona.ts +60 -0
- package/src/simocracy.ts +121 -0
- package/src/training/alignment.ts +259 -0
- package/src/training/apply.ts +271 -0
- package/src/training/baseline.ts +159 -0
- package/src/training/chat.ts +131 -0
- package/src/training/feedback.ts +81 -0
- package/src/training/index.ts +142 -0
- package/src/training/profile.ts +229 -0
- package/src/training/prompt-helpers.ts +70 -0
- package/src/training/prompts.ts +131 -0
- package/src/training/question-set.ts +134 -0
- package/src/training/storage.ts +81 -0
- package/src/training/types.ts +121 -0
- package/src/writes.ts +245 -0
package/src/index.ts
CHANGED
|
@@ -1,19 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-simocracy — load a Simocracy sim into your pi chat
|
|
2
|
+
* pi-simocracy — load a Simocracy sim into your pi chat, train its
|
|
3
|
+
* constitution, and write it back to your ATProto PDS.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - `/sim unload`
|
|
10
|
-
* - `/sim status`
|
|
5
|
+
* Sim commands:
|
|
6
|
+
* - `/sim <name>` Load a sim by name (fuzzy search on the indexer).
|
|
7
|
+
* Renders the sprite inline as colored ANSI art and
|
|
8
|
+
* pushes the sim's constitution + style into the
|
|
9
|
+
* system prompt so pi roleplays as the sim.
|
|
10
|
+
* - `/sim unload` Drop the loaded sim and stop roleplaying.
|
|
11
|
+
* - `/sim status` Show the currently loaded sim, if any.
|
|
12
|
+
*
|
|
13
|
+
* Constitution training (works on the loaded sim):
|
|
14
|
+
* - `/sim interview [name] [--apply]`
|
|
15
|
+
* Adaptive AI interview that derives a sim's constitution +
|
|
16
|
+
* speaking style from your answers. With `--apply` writes them
|
|
17
|
+
* back to your PDS (requires `/sim login`).
|
|
18
|
+
* - `/sim train baseline`
|
|
19
|
+
* Vote yes/no/abstain on sample proposals (5+ recommended).
|
|
20
|
+
* - `/sim train chat`
|
|
21
|
+
* Conversational training round — the sim asks targeted
|
|
22
|
+
* questions about gaps it sees in the baseline votes.
|
|
23
|
+
* - `/sim train profile`
|
|
24
|
+
* Distill the baseline + chat transcript into a structured
|
|
25
|
+
* TrainingProfile (priorities, red lines, tradeoffs).
|
|
26
|
+
* - `/sim train alignment`
|
|
27
|
+
* Score the sim against your hidden baseline and report match%.
|
|
28
|
+
* - `/sim train apply [--apply]`
|
|
29
|
+
* Merge the profile into the constitution. Without `--apply`
|
|
30
|
+
* copies the merged markdown to clipboard. With `--apply`
|
|
31
|
+
* writes to your PDS (requires `/sim login`).
|
|
32
|
+
* - `/sim train feedback`, `status`, `reset`
|
|
33
|
+
* Free-form feedback chat, status, and clear local state.
|
|
34
|
+
*
|
|
35
|
+
* ATProto sign-in ("sign in with Bluesky / ATProto", NOT Anthropic):
|
|
36
|
+
* - `/sim login [handle]`
|
|
37
|
+
* Loopback OAuth flow — opens your PDS's authorize page in the
|
|
38
|
+
* browser, grants this CLI a DPoP-bound session, persists it to
|
|
39
|
+
* ~/.config/pi-simocracy/auth.json. Required before `--apply`
|
|
40
|
+
* writes records to your repo.
|
|
41
|
+
* - `/sim logout` Clear the local OAuth session.
|
|
42
|
+
* - `/sim whoami` Show the currently signed-in ATProto handle/DID.
|
|
43
|
+
*
|
|
44
|
+
* Browse your own sims (requires `/sim login`):
|
|
45
|
+
* - `/sim my` List the org.simocracy.sim records owned by
|
|
46
|
+
* the signed-in DID, newest first. Mirrors
|
|
47
|
+
* simocracy.org's My Sims page in terminal form.
|
|
48
|
+
* - `/sim my <n>` Load sim #n from that list (1-indexed).
|
|
49
|
+
* - `/sim my <name>` Fuzzy-load by name within your own sims.
|
|
11
50
|
*
|
|
12
51
|
* Tools (LLM-callable):
|
|
13
|
-
* - `simocracy_load_sim`
|
|
14
|
-
* - `simocracy_unload_sim`
|
|
15
|
-
* - `simocracy_chat`
|
|
16
|
-
*
|
|
52
|
+
* - `simocracy_load_sim` Same as /sim <name>.
|
|
53
|
+
* - `simocracy_unload_sim` Same as /sim unload.
|
|
54
|
+
* - `simocracy_chat` One-shot chat with a sim via
|
|
55
|
+
* OpenRouter (does not change the
|
|
56
|
+
* active session persona).
|
|
57
|
+
* - `simocracy_run_interview` Run /sim interview programmatically.
|
|
58
|
+
* - `simocracy_derive_constitution`
|
|
59
|
+
* Given interview answers, return the
|
|
60
|
+
* derived constitution + style.
|
|
61
|
+
* - `simocracy_training_profile` Distill baseline + transcript into
|
|
62
|
+
* a TrainingProfile.
|
|
63
|
+
* - `simocracy_alignment_test` Score a profile against a baseline.
|
|
64
|
+
*
|
|
65
|
+
* Note on /login: pi itself ships a built-in `/login` for Anthropic OAuth.
|
|
66
|
+
* To avoid the collision (and to make it explicit you're signing into
|
|
67
|
+
* ATProto, not Anthropic), all auth commands here are namespaced under
|
|
68
|
+
* `/sim`.
|
|
17
69
|
*/
|
|
18
70
|
|
|
19
71
|
import type {
|
|
@@ -26,6 +78,7 @@ import { Type } from "typebox";
|
|
|
26
78
|
|
|
27
79
|
import {
|
|
28
80
|
searchSimsByName,
|
|
81
|
+
fetchSimsForDid,
|
|
29
82
|
fetchAgentsForSim,
|
|
30
83
|
fetchStyleForSim,
|
|
31
84
|
fetchBlob,
|
|
@@ -43,24 +96,34 @@ import {
|
|
|
43
96
|
downscaleRgbaNearest,
|
|
44
97
|
} from "./png-to-ansi.ts";
|
|
45
98
|
import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
|
|
99
|
+
import { buildSimPrompt, type LoadedSim } from "./persona.ts";
|
|
100
|
+
import { runTrainCommand } from "./training/index.ts";
|
|
101
|
+
import {
|
|
102
|
+
runInterviewFlow,
|
|
103
|
+
snapshotInterviewTemplate,
|
|
104
|
+
deriveFromInterview,
|
|
105
|
+
} from "./interview.ts";
|
|
106
|
+
import { deriveProfile, printProfile } from "./training/profile.ts";
|
|
107
|
+
import {
|
|
108
|
+
loadTrainingLabState,
|
|
109
|
+
saveTrainingLabState,
|
|
110
|
+
} from "./training/storage.ts";
|
|
111
|
+
import { scoreAlignment, printAlignment } from "./training/alignment.ts";
|
|
112
|
+
import type {
|
|
113
|
+
AlignmentResult,
|
|
114
|
+
BaselineProposal,
|
|
115
|
+
BaselineVote,
|
|
116
|
+
InterviewTurn,
|
|
117
|
+
TrainingProfile,
|
|
118
|
+
Vote,
|
|
119
|
+
} from "./training/types.ts";
|
|
120
|
+
import { runLogin, runLogout, runWhoami } from "./auth/commands.ts";
|
|
121
|
+
import { readAuth } from "./auth/storage.ts";
|
|
46
122
|
|
|
47
123
|
// ---------------------------------------------------------------------------
|
|
48
124
|
// State
|
|
49
125
|
// ---------------------------------------------------------------------------
|
|
50
126
|
|
|
51
|
-
interface LoadedSim {
|
|
52
|
-
uri: string;
|
|
53
|
-
did: string;
|
|
54
|
-
rkey: string;
|
|
55
|
-
name: string;
|
|
56
|
-
handle: string | null;
|
|
57
|
-
shortDescription?: string;
|
|
58
|
-
description?: string;
|
|
59
|
-
style?: string;
|
|
60
|
-
/** Pre-rendered colored ANSI art of the sim's sprite (4 walk frames). */
|
|
61
|
-
spriteAnsi?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
127
|
let loadedSim: LoadedSim | null = null;
|
|
65
128
|
/**
|
|
66
129
|
* Name of the most recently unloaded sim, if any. Cleared after the next
|
|
@@ -147,41 +210,6 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
147
210
|
return null;
|
|
148
211
|
}
|
|
149
212
|
|
|
150
|
-
function buildSimPrompt(sim: LoadedSim): string {
|
|
151
|
-
const lines: string[] = [];
|
|
152
|
-
lines.push(`# Simocracy roleplay`);
|
|
153
|
-
lines.push(
|
|
154
|
-
`You are now roleplaying as **${sim.name}**, a Simocracy sim — a simulated political agent in a decentralized governance simulation built on the AT Protocol.`,
|
|
155
|
-
);
|
|
156
|
-
lines.push(
|
|
157
|
-
`Stay in character at all times. Respond as ${sim.name} would — with their beliefs, values, and personality. Use first person. Don't break character or mention that you are an AI.`,
|
|
158
|
-
);
|
|
159
|
-
if (sim.handle) lines.push(`The sim's owner on ATProto is @${sim.handle} (${sim.did}).`);
|
|
160
|
-
if (sim.shortDescription) {
|
|
161
|
-
lines.push(``);
|
|
162
|
-
lines.push(`## ${sim.name}'s identity`);
|
|
163
|
-
lines.push(sim.shortDescription);
|
|
164
|
-
}
|
|
165
|
-
if (sim.description) {
|
|
166
|
-
lines.push(``);
|
|
167
|
-
lines.push(`## ${sim.name}'s constitution`);
|
|
168
|
-
lines.push(sim.description);
|
|
169
|
-
}
|
|
170
|
-
if (sim.style) {
|
|
171
|
-
lines.push(``);
|
|
172
|
-
lines.push(`## ${sim.name}'s speaking style`);
|
|
173
|
-
lines.push(sim.style);
|
|
174
|
-
}
|
|
175
|
-
lines.push(``);
|
|
176
|
-
lines.push(
|
|
177
|
-
`When the user asks you to use any of pi's tools (read, edit, bash, etc.), you should still use them — you're ${sim.name} *with access to a developer's terminal*. Just narrate tool use the way ${sim.name} would talk about it.`,
|
|
178
|
-
);
|
|
179
|
-
lines.push(
|
|
180
|
-
`Keep replies conversational unless the user explicitly asks for code or a long answer.`,
|
|
181
|
-
);
|
|
182
|
-
return lines.join("\n");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
213
|
async function loadSimByName(query: string): Promise<{
|
|
186
214
|
matches: SimMatch[];
|
|
187
215
|
loaded?: LoadedSim;
|
|
@@ -275,6 +303,96 @@ const ChatToolParams = Type.Object({
|
|
|
275
303
|
|
|
276
304
|
const UnloadToolParams = Type.Object({});
|
|
277
305
|
|
|
306
|
+
const RunInterviewToolParams = Type.Object({
|
|
307
|
+
sim: Type.Optional(
|
|
308
|
+
Type.String({
|
|
309
|
+
description:
|
|
310
|
+
"Sim name or AT-URI to interview. Defaults to the currently loaded sim if omitted.",
|
|
311
|
+
}),
|
|
312
|
+
),
|
|
313
|
+
templateUri: Type.Optional(
|
|
314
|
+
Type.String({
|
|
315
|
+
description:
|
|
316
|
+
"AT-URI of an `org.simocracy.interviewTemplate` to use. Skips the picker.",
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const OpenAnswerSchema = Type.Object({
|
|
322
|
+
question: Type.String(),
|
|
323
|
+
answer: Type.String(),
|
|
324
|
+
});
|
|
325
|
+
const YesNoAnswerSchema = Type.Object({
|
|
326
|
+
statement: Type.String(),
|
|
327
|
+
answer: Type.Boolean(),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const DeriveConstitutionToolParams = Type.Object({
|
|
331
|
+
openAnswers: Type.Optional(Type.Array(OpenAnswerSchema)),
|
|
332
|
+
yesNoAnswers: Type.Optional(Type.Array(YesNoAnswerSchema)),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const BaselineProposalSchema = Type.Object({
|
|
336
|
+
id: Type.String(),
|
|
337
|
+
title: Type.String(),
|
|
338
|
+
summary: Type.String(),
|
|
339
|
+
topic: Type.String(),
|
|
340
|
+
});
|
|
341
|
+
const BaselineVoteSchema = Type.Object({
|
|
342
|
+
proposalId: Type.String(),
|
|
343
|
+
vote: Type.Union([Type.Literal("yes"), Type.Literal("no"), Type.Literal("abstain")]),
|
|
344
|
+
importance: Type.Number(),
|
|
345
|
+
reasoning: Type.String(),
|
|
346
|
+
});
|
|
347
|
+
const InterviewTurnSchema = Type.Object({
|
|
348
|
+
role: Type.Union([Type.Literal("assistant"), Type.Literal("user")]),
|
|
349
|
+
content: Type.String(),
|
|
350
|
+
target: Type.Optional(Type.String()),
|
|
351
|
+
});
|
|
352
|
+
const IssuePrioritySchema = Type.Object({
|
|
353
|
+
issue: Type.String(),
|
|
354
|
+
stance: Type.String(),
|
|
355
|
+
importance: Type.Number(),
|
|
356
|
+
negotiability: Type.Number(),
|
|
357
|
+
confidence: Type.Number(),
|
|
358
|
+
});
|
|
359
|
+
const TrainingProfileSchema = Type.Object({
|
|
360
|
+
summary: Type.String(),
|
|
361
|
+
coreValues: Type.Array(Type.String()),
|
|
362
|
+
issuePriorities: Type.Array(IssuePrioritySchema),
|
|
363
|
+
redLines: Type.Array(Type.String()),
|
|
364
|
+
acceptableTradeoffs: Type.Array(Type.String()),
|
|
365
|
+
uncertaintyAreas: Type.Array(Type.String()),
|
|
366
|
+
representationRules: Type.Array(Type.String()),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const TrainingProfileToolParams = Type.Object({
|
|
370
|
+
simName: Type.String({ description: "The sim's display name." }),
|
|
371
|
+
existingConstitution: Type.Optional(Type.String()),
|
|
372
|
+
baselineVotes: Type.Array(BaselineVoteSchema),
|
|
373
|
+
baselineProposals: Type.Array(BaselineProposalSchema),
|
|
374
|
+
transcript: Type.Array(InterviewTurnSchema),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const AlignmentProposalSchema = Type.Object({
|
|
378
|
+
id: Type.String(),
|
|
379
|
+
title: Type.String(),
|
|
380
|
+
summary: Type.String(),
|
|
381
|
+
topic: Type.String(),
|
|
382
|
+
userVote: Type.Union([
|
|
383
|
+
Type.Literal("yes"),
|
|
384
|
+
Type.Literal("no"),
|
|
385
|
+
Type.Literal("abstain"),
|
|
386
|
+
]),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const AlignmentTestToolParams = Type.Object({
|
|
390
|
+
simName: Type.String({ description: "The sim's display name." }),
|
|
391
|
+
existingConstitution: Type.Optional(Type.String()),
|
|
392
|
+
profile: TrainingProfileSchema,
|
|
393
|
+
proposals: Type.Array(AlignmentProposalSchema),
|
|
394
|
+
});
|
|
395
|
+
|
|
278
396
|
export default async function simocracy(pi: ExtensionAPI) {
|
|
279
397
|
// -------------------------------------------------------------------------
|
|
280
398
|
// System prompt injection — every turn the loaded sim's persona is appended.
|
|
@@ -316,20 +434,70 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
316
434
|
|
|
317
435
|
// -------------------------------------------------------------------------
|
|
318
436
|
// Slash command: /sim
|
|
437
|
+
//
|
|
438
|
+
// All extension commands are namespaced under /sim to avoid colliding
|
|
439
|
+
// with pi's built-in slash commands (notably `/login` for Anthropic
|
|
440
|
+
// OAuth and `/logout`). That namespacing also makes it unambiguous to
|
|
441
|
+
// users that `/sim login` signs them into their ATProto / Bluesky
|
|
442
|
+
// account, NOT into Anthropic.
|
|
319
443
|
// -------------------------------------------------------------------------
|
|
320
444
|
pi.registerCommand("sim", {
|
|
321
|
-
description:
|
|
445
|
+
description:
|
|
446
|
+
"Simocracy: load/train sims, sign into ATProto. `/sim help` for the full list.",
|
|
322
447
|
handler: async (args, ctx) => {
|
|
323
448
|
const arg = args.trim();
|
|
324
449
|
if (!arg || arg === "help" || arg === "--help") {
|
|
325
450
|
ctx.ui.notify(
|
|
326
|
-
"
|
|
327
|
-
"
|
|
328
|
-
"
|
|
451
|
+
"Sim:\n" +
|
|
452
|
+
" /sim <name> load a sim (e.g. /sim mr meow)\n" +
|
|
453
|
+
" /sim unload stop roleplaying\n" +
|
|
454
|
+
" /sim status show currently loaded sim\n" +
|
|
455
|
+
"\n" +
|
|
456
|
+
"Constitution training (operates on the loaded sim):\n" +
|
|
457
|
+
" /sim interview [name] adaptive interview → derive constitution\n" +
|
|
458
|
+
" /sim train baseline vote on sample proposals\n" +
|
|
459
|
+
" /sim train chat conversational training round\n" +
|
|
460
|
+
" /sim train profile distill votes + chat → TrainingProfile\n" +
|
|
461
|
+
" /sim train alignment score sim against your baseline\n" +
|
|
462
|
+
" /sim train apply merge profile into constitution\n" +
|
|
463
|
+
" /sim train status|reset|feedback\n" +
|
|
464
|
+
"\n" +
|
|
465
|
+
"Sign in with ATProto / Bluesky (not Anthropic — pi's built-in /login\n" +
|
|
466
|
+
"does that). Required before `--apply` writes to your PDS:\n" +
|
|
467
|
+
" /sim login [handle] OAuth loopback flow (e.g. /sim login alice.bsky.social)\n" +
|
|
468
|
+
" /sim logout clear local session\n" +
|
|
469
|
+
" /sim whoami show signed-in handle/DID\n" +
|
|
470
|
+
"\n" +
|
|
471
|
+
"Browse your own sims (requires /sim login):\n" +
|
|
472
|
+
" /sim my list sims you own on your PDS\n" +
|
|
473
|
+
" /sim my <n> load sim #n from that list\n" +
|
|
474
|
+
" /sim my <name> fuzzy-load by name within your sims",
|
|
329
475
|
"info",
|
|
330
476
|
);
|
|
331
477
|
return;
|
|
332
478
|
}
|
|
479
|
+
// ATProto auth subcommands — must come BEFORE the sim-name
|
|
480
|
+
// fallthrough (`runLoadFlow`) so we don't accidentally treat
|
|
481
|
+
// "login" as a sim name to load from the indexer.
|
|
482
|
+
if (arg === "login" || arg.startsWith("login ") || arg.startsWith("login\t")) {
|
|
483
|
+
const rest = arg.slice("login".length).trim();
|
|
484
|
+
await runLogin(ctx, rest);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (arg === "logout") {
|
|
488
|
+
await runLogout(ctx);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (arg === "whoami") {
|
|
492
|
+
await runWhoami(ctx);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (arg === "my" || arg === "mine" || arg.startsWith("my ") || arg.startsWith("my\t") || arg.startsWith("mine ") || arg.startsWith("mine\t")) {
|
|
496
|
+
const headLen = arg.startsWith("mine") ? 4 : 2;
|
|
497
|
+
const rest = arg.slice(headLen).trim();
|
|
498
|
+
await runMySimsCommand(pi, ctx, rest);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
333
501
|
if (arg === "unload" || arg === "clear") {
|
|
334
502
|
if (!loadedSim) {
|
|
335
503
|
ctx.ui.notify("No sim loaded.", "info");
|
|
@@ -351,10 +519,56 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
351
519
|
await postSimToChat(pi, ctx, loadedSim, /*reload=*/ false);
|
|
352
520
|
return;
|
|
353
521
|
}
|
|
522
|
+
if (arg === "interview" || arg.startsWith("interview ") || arg.startsWith("interview\t")) {
|
|
523
|
+
const rest = arg.slice("interview".length).trim();
|
|
524
|
+
// Strip recognised flags (--apply) and use the rest as a sim name.
|
|
525
|
+
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
526
|
+
const apply = tokens.includes("--apply");
|
|
527
|
+
const nameTokens = tokens.filter((t) => !t.startsWith("--"));
|
|
528
|
+
const simName = nameTokens.join(" ").trim();
|
|
529
|
+
if (simName && !loadedSim) {
|
|
530
|
+
const sim = await tryLoadFromQuery(simName);
|
|
531
|
+
if (!sim) {
|
|
532
|
+
ctx.ui.notify(`No sim found matching "${simName}".`, "error");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
loadedSim = sim;
|
|
536
|
+
await postSimToChat(pi, ctx, sim, /*reload=*/ true);
|
|
537
|
+
}
|
|
538
|
+
if (!loadedSim) {
|
|
539
|
+
ctx.ui.notify("No sim loaded. Use `/sim <name>` first or pass a name to `/sim interview <name>`.", "error");
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
await runInterviewFlow(pi, ctx, loadedSim, { apply });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (arg === "train" || arg.startsWith("train ") || arg.startsWith("train\t")) {
|
|
546
|
+
const rest = arg.slice("train".length).trim();
|
|
547
|
+
await runTrainCommand(ctx, rest, loadedSim);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
354
550
|
await runLoadFlow(pi, ctx, arg);
|
|
355
551
|
},
|
|
356
552
|
});
|
|
357
553
|
|
|
554
|
+
// -------------------------------------------------------------------------
|
|
555
|
+
// (Removed) top-level /login, /logout, /whoami slash commands.
|
|
556
|
+
//
|
|
557
|
+
// These collided with pi's own built-in `/login` (Anthropic OAuth) and
|
|
558
|
+
// `/logout`, which made pi emit "Skipping in autocomplete" warnings on
|
|
559
|
+
// every boot and silently degraded discoverability of these handlers.
|
|
560
|
+
// The auth flow now lives under `/sim login`, `/sim logout`,
|
|
561
|
+
// `/sim whoami` — no collision, and the namespacing makes it explicit
|
|
562
|
+
// to users that they're signing into their ATProto / Bluesky account,
|
|
563
|
+
// not Anthropic. See the dispatcher in the `/sim` registerCommand
|
|
564
|
+
// above.
|
|
565
|
+
//
|
|
566
|
+
// The `runLogin` / `runLogout` / `runWhoami` helpers in src/auth/
|
|
567
|
+
// commands.ts are unchanged — only the slash-command surface moved.
|
|
568
|
+
// -------------------------------------------------------------------------
|
|
569
|
+
// (no top-level registration — the auth helpers `runLogin`, `runLogout`,
|
|
570
|
+
// `runWhoami` are dispatched from inside the `/sim` handler above.)
|
|
571
|
+
|
|
358
572
|
// -------------------------------------------------------------------------
|
|
359
573
|
// Tool: simocracy_load_sim
|
|
360
574
|
// -------------------------------------------------------------------------
|
|
@@ -457,12 +671,380 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
457
671
|
};
|
|
458
672
|
},
|
|
459
673
|
});
|
|
674
|
+
|
|
675
|
+
// -------------------------------------------------------------------------
|
|
676
|
+
// Tool: simocracy_run_interview — run the interview questionnaire.
|
|
677
|
+
// -------------------------------------------------------------------------
|
|
678
|
+
pi.registerTool({
|
|
679
|
+
name: "simocracy_run_interview",
|
|
680
|
+
label: "Run Simocracy interview",
|
|
681
|
+
description:
|
|
682
|
+
"Run the Simocracy interview questionnaire on the loaded sim and return the captured open + yes/no answers. With UI, drives the user through the questions interactively. Without UI, returns the structure of the chosen template as a planning aid (no answers).",
|
|
683
|
+
parameters: RunInterviewToolParams,
|
|
684
|
+
async execute(_id, { sim, templateUri }, _signal, _onUpdate, ctx) {
|
|
685
|
+
if (ctx.hasUI) {
|
|
686
|
+
let target: LoadedSim | null = loadedSim;
|
|
687
|
+
if (sim) {
|
|
688
|
+
const loaded = await tryLoadFromQuery(sim);
|
|
689
|
+
if (!loaded) throw new Error(`No sim found matching "${sim}".`);
|
|
690
|
+
loadedSim = loaded;
|
|
691
|
+
target = loaded;
|
|
692
|
+
await postSimToChat(pi, ctx, loaded, true);
|
|
693
|
+
}
|
|
694
|
+
if (!target) throw new Error("No sim loaded. Pass `sim` or call simocracy_load_sim first.");
|
|
695
|
+
const out = await runInterviewFlow(pi, ctx as ExtensionCommandContext, target, {
|
|
696
|
+
templateUri,
|
|
697
|
+
});
|
|
698
|
+
if (!out) {
|
|
699
|
+
return {
|
|
700
|
+
content: [{ type: "text" as const, text: "Interview cancelled." }],
|
|
701
|
+
details: { cancelled: true },
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
content: [
|
|
706
|
+
{
|
|
707
|
+
type: "text" as const,
|
|
708
|
+
text: `Captured ${out.result.openAnswers.length} open answers and ${out.result.yesNoAnswers.length} value positions.`,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
details: out.result,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
// No UI: return the template snapshot as a planning aid.
|
|
715
|
+
const snapshot = await snapshotInterviewTemplate(templateUri);
|
|
716
|
+
return {
|
|
717
|
+
content: [
|
|
718
|
+
{
|
|
719
|
+
type: "text" as const,
|
|
720
|
+
text:
|
|
721
|
+
`Interview template "${snapshot.name}" — ${snapshot.questions.length} questions. ` +
|
|
722
|
+
`Run from a UI session to capture answers.`,
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
details: { template: snapshot, openAnswers: [], yesNoAnswers: [] },
|
|
726
|
+
};
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// -------------------------------------------------------------------------
|
|
731
|
+
// Tool: simocracy_derive_constitution — turn answers into constitution+style.
|
|
732
|
+
// -------------------------------------------------------------------------
|
|
733
|
+
pi.registerTool({
|
|
734
|
+
name: "simocracy_derive_constitution",
|
|
735
|
+
label: "Derive Simocracy constitution",
|
|
736
|
+
description:
|
|
737
|
+
"Given an interview's open + yes/no answers, derive a sim constitution (short description + markdown body) and a speaking style. Returns plain markdown — no PDS write happens here.",
|
|
738
|
+
parameters: DeriveConstitutionToolParams,
|
|
739
|
+
async execute(_id, { openAnswers, yesNoAnswers }) {
|
|
740
|
+
const sim: LoadedSim = loadedSim ?? {
|
|
741
|
+
uri: "",
|
|
742
|
+
did: "",
|
|
743
|
+
rkey: "",
|
|
744
|
+
name: "Sim",
|
|
745
|
+
handle: null,
|
|
746
|
+
};
|
|
747
|
+
const derived = await deriveFromInterview(sim, {
|
|
748
|
+
openAnswers: openAnswers ?? [],
|
|
749
|
+
yesNoAnswers: yesNoAnswers ?? [],
|
|
750
|
+
});
|
|
751
|
+
if (!derived) {
|
|
752
|
+
throw new Error("Could not parse derive-from-interview model output.");
|
|
753
|
+
}
|
|
754
|
+
const summary = [
|
|
755
|
+
`Short description:`,
|
|
756
|
+
derived.constitution.shortDescription,
|
|
757
|
+
``,
|
|
758
|
+
`Constitution:`,
|
|
759
|
+
derived.constitution.description,
|
|
760
|
+
``,
|
|
761
|
+
`Speaking style:`,
|
|
762
|
+
derived.style.description,
|
|
763
|
+
].join("\n");
|
|
764
|
+
return {
|
|
765
|
+
content: [{ type: "text" as const, text: summary }],
|
|
766
|
+
details: derived,
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// -------------------------------------------------------------------------
|
|
772
|
+
// Tool: simocracy_training_profile — distill into a TrainingProfile.
|
|
773
|
+
// -------------------------------------------------------------------------
|
|
774
|
+
pi.registerTool({
|
|
775
|
+
name: "simocracy_training_profile",
|
|
776
|
+
label: "Distill Simocracy training profile",
|
|
777
|
+
description:
|
|
778
|
+
"Distill a baseline questionnaire + interview transcript into a structured TrainingProfile (summary, core values, issue priorities, red lines, etc.). Read-only — does not write to the sim.",
|
|
779
|
+
parameters: TrainingProfileToolParams,
|
|
780
|
+
async execute(_id, params) {
|
|
781
|
+
const sim: LoadedSim = loadedSim ?? {
|
|
782
|
+
uri: "",
|
|
783
|
+
did: "",
|
|
784
|
+
rkey: "",
|
|
785
|
+
name: params.simName,
|
|
786
|
+
handle: null,
|
|
787
|
+
description: params.existingConstitution,
|
|
788
|
+
};
|
|
789
|
+
const stateSnapshot = {
|
|
790
|
+
baselineVotes: params.baselineVotes as BaselineVote[],
|
|
791
|
+
interviewTurns: params.transcript as InterviewTurn[],
|
|
792
|
+
feedbackTurns: [] as never[],
|
|
793
|
+
profile: null as TrainingProfile | null,
|
|
794
|
+
alignment: null as AlignmentResult | null,
|
|
795
|
+
updatedAt: new Date().toISOString(),
|
|
796
|
+
};
|
|
797
|
+
const profile = await deriveProfile(
|
|
798
|
+
{ ...sim, description: params.existingConstitution ?? sim.description },
|
|
799
|
+
stateSnapshot,
|
|
800
|
+
params.baselineProposals as BaselineProposal[],
|
|
801
|
+
);
|
|
802
|
+
if (!profile) throw new Error("Could not parse training profile from model output.");
|
|
803
|
+
if (loadedSim && loadedSim.rkey) {
|
|
804
|
+
const persisted = loadTrainingLabState(loadedSim.rkey);
|
|
805
|
+
saveTrainingLabState(loadedSim.rkey, { ...persisted, profile, alignment: null });
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
printProfile(profile);
|
|
809
|
+
} catch {
|
|
810
|
+
/* best effort */
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
content: [
|
|
814
|
+
{
|
|
815
|
+
type: "text" as const,
|
|
816
|
+
text: `Distilled profile: ${profile.coreValues.length} core values, ${profile.issuePriorities.length} issue priorities, ${profile.redLines.length} red lines.`,
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
details: profile,
|
|
820
|
+
};
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// -------------------------------------------------------------------------
|
|
825
|
+
// Tool: simocracy_alignment_test — score the sim against baseline votes.
|
|
826
|
+
// -------------------------------------------------------------------------
|
|
827
|
+
pi.registerTool({
|
|
828
|
+
name: "simocracy_alignment_test",
|
|
829
|
+
label: "Run Simocracy alignment test",
|
|
830
|
+
description:
|
|
831
|
+
"Run the loaded sim's training profile against a list of proposals (each with the user's hidden vote) and return per-proposal match/mismatch + an overall match percentage. Calls the alignment prompt once per proposal at concurrency 4.",
|
|
832
|
+
parameters: AlignmentTestToolParams,
|
|
833
|
+
async execute(_id, params) {
|
|
834
|
+
const sim: LoadedSim = loadedSim ?? {
|
|
835
|
+
uri: "",
|
|
836
|
+
did: "",
|
|
837
|
+
rkey: "",
|
|
838
|
+
name: params.simName,
|
|
839
|
+
handle: null,
|
|
840
|
+
description: params.existingConstitution,
|
|
841
|
+
};
|
|
842
|
+
const aligned = (params.proposals as Array<BaselineProposal & { userVote: Vote }>).map(
|
|
843
|
+
(p) => ({
|
|
844
|
+
proposal: { id: p.id, title: p.title, summary: p.summary, topic: p.topic },
|
|
845
|
+
userVote: p.userVote,
|
|
846
|
+
}),
|
|
847
|
+
);
|
|
848
|
+
const alignment = await scoreAlignment(
|
|
849
|
+
{ ...sim, description: params.existingConstitution ?? sim.description },
|
|
850
|
+
params.profile as TrainingProfile,
|
|
851
|
+
aligned,
|
|
852
|
+
);
|
|
853
|
+
if (loadedSim && loadedSim.rkey) {
|
|
854
|
+
const persisted = loadTrainingLabState(loadedSim.rkey);
|
|
855
|
+
saveTrainingLabState(loadedSim.rkey, { ...persisted, alignment });
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
printAlignment(alignment, aligned);
|
|
859
|
+
} catch {
|
|
860
|
+
/* not in UI mode — printing is best-effort */
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
content: [
|
|
864
|
+
{
|
|
865
|
+
type: "text" as const,
|
|
866
|
+
text: `Alignment: ${alignment.matchedCount}/${alignment.totalCount} matched. Weak areas: ${
|
|
867
|
+
alignment.weakAreas.length ? alignment.weakAreas.join(", ") : "none"
|
|
868
|
+
}.`,
|
|
869
|
+
},
|
|
870
|
+
],
|
|
871
|
+
details: alignment,
|
|
872
|
+
};
|
|
873
|
+
},
|
|
874
|
+
});
|
|
460
875
|
}
|
|
461
876
|
|
|
877
|
+
|
|
462
878
|
// ---------------------------------------------------------------------------
|
|
463
879
|
// Slash-command flow
|
|
464
880
|
// ---------------------------------------------------------------------------
|
|
465
881
|
|
|
882
|
+
/**
|
|
883
|
+
* `/sim my [arg]` — list (and optionally load) sims owned by the
|
|
884
|
+
* currently signed-in DID. Three calling conventions, picked by
|
|
885
|
+
* cheapest-first:
|
|
886
|
+
*
|
|
887
|
+
* - bare `/sim my` → print a numbered list, no load
|
|
888
|
+
* - `/sim my <n>` (digits) → load the n-th sim from the list
|
|
889
|
+
* - `/sim my <text>` → fuzzy-match by name within the user's
|
|
890
|
+
* sims (uses the same scoring as the
|
|
891
|
+
* global indexer search), load the best.
|
|
892
|
+
* If two of the user's sims share a name
|
|
893
|
+
* we prompt with a select. AT-URIs are
|
|
894
|
+
* not allowed here — use `/sim <at-uri>`
|
|
895
|
+
* directly for that.
|
|
896
|
+
*
|
|
897
|
+
* Reads the user's sims from their PDS via `com.atproto.repo.listRecords`
|
|
898
|
+
* (no DPoP needed for reads of the public collection), so this works
|
|
899
|
+
* even if the OAuth session has expired — it only needs the DID, which
|
|
900
|
+
* the auth.json keeps after `lastLogin` is stale.
|
|
901
|
+
*/
|
|
902
|
+
async function runMySimsCommand(
|
|
903
|
+
pi: ExtensionAPI,
|
|
904
|
+
ctx: ExtensionCommandContext,
|
|
905
|
+
arg: string,
|
|
906
|
+
): Promise<void> {
|
|
907
|
+
const auth = readAuth();
|
|
908
|
+
if (!auth) {
|
|
909
|
+
ctx.ui.notify(
|
|
910
|
+
"Not signed into ATProto. Run `/sim login <handle>` first (e.g. `/sim login alice.bsky.social`) so /sim my knows which DID's repo to list.",
|
|
911
|
+
"error",
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
ctx.ui.notify(`Listing sims owned by ${auth.handle ? `@${auth.handle}` : auth.did}\u2026`, "info");
|
|
917
|
+
|
|
918
|
+
let mySims: SimMatch[];
|
|
919
|
+
try {
|
|
920
|
+
mySims = await fetchSimsForDid(auth.did);
|
|
921
|
+
} catch (err) {
|
|
922
|
+
ctx.ui.notify(
|
|
923
|
+
`Could not list sims from your PDS: ${(err as Error).message}. Is the DID document still resolvable?`,
|
|
924
|
+
"error",
|
|
925
|
+
);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (mySims.length === 0) {
|
|
930
|
+
ctx.ui.notify(
|
|
931
|
+
auth.handle
|
|
932
|
+
? `@${auth.handle} doesn't own any sims yet. Visit https://simocracy.org/my-sims to create one, then come back and try /sim my again.`
|
|
933
|
+
: `No sims found on this PDS. Create one at https://simocracy.org/my-sims and try again.`,
|
|
934
|
+
"info",
|
|
935
|
+
);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Bare `/sim my` — just list. Indented so the chat treats this as
|
|
940
|
+
// a content message (notify is best for short feedback; for the
|
|
941
|
+
// list we want fixed-width alignment so we go through the registered
|
|
942
|
+
// message renderer that the load flow already uses for sim summaries).
|
|
943
|
+
if (!arg) {
|
|
944
|
+
const lines: string[] = [];
|
|
945
|
+
lines.push(`📁 ${mySims.length} sim${mySims.length === 1 ? "" : "s"} owned by ${auth.handle ? `@${auth.handle}` : auth.did}:`);
|
|
946
|
+
lines.push("");
|
|
947
|
+
const maxNameLen = Math.min(
|
|
948
|
+
32,
|
|
949
|
+
mySims.reduce((m, s) => Math.max(m, s.sim.name.length), 0),
|
|
950
|
+
);
|
|
951
|
+
mySims.forEach((s, i) => {
|
|
952
|
+
const idx = String(i + 1).padStart(2, " ");
|
|
953
|
+
const name = s.sim.name.length > maxNameLen ? s.sim.name.slice(0, maxNameLen - 1) + "…" : s.sim.name.padEnd(maxNameLen, " ");
|
|
954
|
+
const created = (s.sim.createdAt || "").slice(0, 10);
|
|
955
|
+
lines.push(` ${idx}. ${name} ${created} at://…/${s.rkey}`);
|
|
956
|
+
});
|
|
957
|
+
lines.push("");
|
|
958
|
+
lines.push("Load one with `/sim my <n>` (e.g. `/sim my 1`) or `/sim my <name>`.");
|
|
959
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// `/sim my <n>` — numeric index into the list above.
|
|
964
|
+
if (/^\d+$/.test(arg)) {
|
|
965
|
+
const n = parseInt(arg, 10);
|
|
966
|
+
if (n < 1 || n > mySims.length) {
|
|
967
|
+
ctx.ui.notify(
|
|
968
|
+
`No sim #${n} — you have ${mySims.length}. Run /sim my (no args) to see the list.`,
|
|
969
|
+
"error",
|
|
970
|
+
);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
await loadAndPostMySim(pi, ctx, mySims[n - 1]);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// `/sim my <name>` — fuzzy-match within the user's own sims.
|
|
978
|
+
const matches = fuzzyMatchOwnedSims(mySims, arg);
|
|
979
|
+
if (matches.length === 0) {
|
|
980
|
+
ctx.ui.notify(
|
|
981
|
+
`No sim matching "${arg}" in your ${mySims.length} sim${mySims.length === 1 ? "" : "s"}. Run /sim my (no args) to see them.`,
|
|
982
|
+
"error",
|
|
983
|
+
);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
let chosen = matches[0];
|
|
987
|
+
if (matches.length > 1 && matches[0].score > 0) {
|
|
988
|
+
// Multiple non-exact matches — ask. Skip the prompt for an exact match.
|
|
989
|
+
const labels = matches.map((m) => `${m.sim.sim.name} — at://…/${m.sim.rkey}`);
|
|
990
|
+
const picked = await ctx.ui.select(`Multiple matches for "${arg}" in your sims`, labels);
|
|
991
|
+
if (!picked) {
|
|
992
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
chosen = matches[labels.indexOf(picked)];
|
|
996
|
+
}
|
|
997
|
+
await loadAndPostMySim(pi, ctx, chosen.sim);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function loadAndPostMySim(
|
|
1001
|
+
pi: ExtensionAPI,
|
|
1002
|
+
ctx: ExtensionCommandContext,
|
|
1003
|
+
match: SimMatch,
|
|
1004
|
+
): Promise<void> {
|
|
1005
|
+
ctx.ui.notify(`Loading ${match.sim.name}…`, "info");
|
|
1006
|
+
let sim: LoadedSim;
|
|
1007
|
+
try {
|
|
1008
|
+
sim = await hydrateLoadedSim(match);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
ctx.ui.notify(`Failed to load sim: ${(err as Error).message}`, "error");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
loadedSim = sim;
|
|
1014
|
+
await postSimToChat(pi, ctx, sim, true);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Score user-owned sims against a query string. Returns matches sorted
|
|
1019
|
+
* best-first. Score 0 = exact name match (prompt-suppressing), higher =
|
|
1020
|
+
* worse. Mirrors the heuristic the indexer search uses but constrained
|
|
1021
|
+
* to the already-fetched list, so this is purely client-side and no
|
|
1022
|
+
* extra HTTP calls are issued.
|
|
1023
|
+
*/
|
|
1024
|
+
function fuzzyMatchOwnedSims(
|
|
1025
|
+
sims: SimMatch[],
|
|
1026
|
+
query: string,
|
|
1027
|
+
): Array<{ sim: SimMatch; score: number }> {
|
|
1028
|
+
const q = query.toLowerCase().trim();
|
|
1029
|
+
const out: Array<{ sim: SimMatch; score: number }> = [];
|
|
1030
|
+
for (const sim of sims) {
|
|
1031
|
+
const name = sim.sim.name.toLowerCase().trim();
|
|
1032
|
+
let score = Number.POSITIVE_INFINITY;
|
|
1033
|
+
if (name === q) score = 0;
|
|
1034
|
+
else if (name.replace(/\s+/g, "") === q.replace(/\s+/g, "")) score = 1;
|
|
1035
|
+
else if (name.startsWith(q)) score = 2;
|
|
1036
|
+
else if (name.includes(q)) score = 3 + (name.length - q.length);
|
|
1037
|
+
else {
|
|
1038
|
+
const tokens = q.split(/\s+/).filter(Boolean);
|
|
1039
|
+
const matched = tokens.filter((t) => name.includes(t)).length;
|
|
1040
|
+
if (matched > 0) score = 100 - matched;
|
|
1041
|
+
}
|
|
1042
|
+
if (Number.isFinite(score)) out.push({ sim, score });
|
|
1043
|
+
}
|
|
1044
|
+
out.sort((a, b) => a.score - b.score);
|
|
1045
|
+
return out;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
466
1048
|
async function runLoadFlow(
|
|
467
1049
|
pi: ExtensionAPI,
|
|
468
1050
|
ctx: ExtensionCommandContext,
|