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