pi-simocracy 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Dao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # pi-simocracy
2
+
3
+ Load a [Simocracy](https://simocracy.org) sim into your [`pi`](https://github.com/mariozechner/pi-coding-agent) chat — see its
4
+ pixel-art sprite render in the terminal and chat with the agent **as that sim**.
5
+
6
+ ```
7
+ /sim mr meow
8
+ ```
9
+
10
+ …fetches Mr Meow from Simocracy's ATProto indexer, renders his 32×32
11
+ sprite as colored ANSI half-blocks directly in the chat, and pushes
12
+ his constitution + speaking style into pi's system prompt so pi
13
+ roleplays as Mr Meow until you `/sim unload`.
14
+
15
+ ![Mr Meow loaded inline in pi](demo/sim-load.gif)
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install npm:pi-simocracy
23
+ ```
24
+
25
+ That's it. Open `pi`, type `/sim mr meow`, and you're talking to the cat.
26
+
27
+ For the optional `simocracy_chat` tool (one-shot conversation through
28
+ OpenRouter without changing the active session persona), set
29
+ `OPENROUTER_API_KEY` in your environment. The slash-command flow
30
+ doesn't need it — it just rewrites pi's system prompt.
31
+
32
+ ---
33
+
34
+ ## Slash commands
35
+
36
+ | Command | What it does |
37
+ |------------------|-------------------------------------------------------------|
38
+ | `/sim <name>` | Load a sim by name (fuzzy search). Multiple matches → picker. |
39
+ | `/sim <at-uri>` | Load a sim by AT-URI directly (no search). |
40
+ | `/sim status` | Show which sim is currently loaded. |
41
+ | `/sim unload` | Drop the persona and break character cleanly. |
42
+ | `/sim help` | Print usage. |
43
+
44
+ Examples:
45
+
46
+ ```
47
+ /sim mr meow
48
+ /sim Marie Curie
49
+ /sim at://did:plc:qc42fmqqlsmdq7jiypiiigww/org.simocracy.sim/3mfo6vwfaka24
50
+ /sim unload
51
+ ```
52
+
53
+ ---
54
+
55
+ ## LLM-callable tools
56
+
57
+ The same actions are exposed to pi as tools, so the model can drive them itself:
58
+
59
+ | Tool | Use when |
60
+ |--------------------------|-----------------------------------------------------------------|
61
+ | `simocracy_load_sim` | Load a sim into the current session (sets the persona). |
62
+ | `simocracy_unload_sim` | Stop roleplaying. |
63
+ | `simocracy_chat` | Send one message to a sim and get a quoted reply, **without** changing the active session persona. Useful for "ask Mr Meow what he thinks of this PR." Requires `OPENROUTER_API_KEY`. |
64
+
65
+ ---
66
+
67
+ ## How it works
68
+
69
+ 1. **Search.** GraphQL query against the public Simocracy indexer
70
+ (`simocracy-indexer-production.up.railway.app`) for `org.simocracy.sim`
71
+ records, then client-side fuzzy ranking by exact match → prefix → substring → token overlap.
72
+ 2. **Resolve.** Parse the winning AT-URI, fetch the DID document from
73
+ `plc.directory` (or the `did:web` well-known URL), follow the
74
+ `#atproto_pds` service endpoint to find the owner's PDS.
75
+ 3. **Hydrate.** Pull three records from the PDS via
76
+ `com.atproto.repo.getRecord` / `listRecords`:
77
+ - `org.simocracy.sim` — display name + sprite + avatar blob refs
78
+ - `org.simocracy.agents` — short description + full constitution
79
+ - `org.simocracy.style` — speaking style / mannerisms
80
+ 4. **Render.** Fetch the sprite-sheet blob (128×128 PNG, 4×4 of 32×32
81
+ walking frames) via `com.atproto.sync.getBlob`, decode with `pngjs`,
82
+ crop the front-facing walk-1 frame, emit as 24-bit ANSI using the
83
+ upper-half-block character `▀` so each terminal cell paints two
84
+ pixels. Transparent regions show pi's background through.
85
+ 5. **Inject.** A `before_agent_start` event handler appends the sim's
86
+ identity + constitution + speaking style to pi's system prompt **every
87
+ turn**. After `/sim unload`, a one-shot override fires on the next
88
+ turn telling the model to break character so it doesn't keep imitating
89
+ its own previous in-character replies.
90
+
91
+ No background processes, no extra terminal windows, no AppleScript — pi
92
+ keeps the terminal it's already running in.
93
+
94
+ ---
95
+
96
+ ## Files
97
+
98
+ ```
99
+ src/
100
+ ├── index.ts # extension entry: slash command, tools, persona injection
101
+ ├── simocracy.ts # indexer + PDS client (read-only)
102
+ ├── png-to-ansi.ts # RGBA half-block ANSI renderer
103
+ └── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
104
+ demo/
105
+ └── sim-load.tape # vhs tape — render with `vhs demo/sim-load.tape`
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Local development
111
+
112
+ ```bash
113
+ git clone https://github.com/GainForest/pi-simocracy
114
+ cd pi-simocracy
115
+ npm install # uses legacy-peer-deps (see .npmrc)
116
+ pi -e $(pwd)/src/index.ts -ne -ns # load the extension directly
117
+ ```
118
+
119
+ Then in `pi`: `/sim mr meow`.
120
+
121
+ To rebuild the demo recording:
122
+
123
+ ```bash
124
+ brew install vhs # one-time
125
+ vhs demo/sim-load.tape # writes demo/sim-load.{webm,gif}
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Required peer dependencies
131
+
132
+ These come bundled with `pi` itself, so installing pi-simocracy via
133
+ `pi install npm:pi-simocracy` already gives you everything:
134
+
135
+ - `@mariozechner/pi-coding-agent` ≥ 0.58.0
136
+ - `@mariozechner/pi-tui` ≥ 0.58.0
137
+
138
+ Direct npm dependencies (auto-installed):
139
+
140
+ - `pngjs` — PNG decoder for sprite blobs
141
+ - `typebox` — tool parameter schemas
142
+
143
+ ---
144
+
145
+ ## Related
146
+
147
+ - **Simocracy** — the governance simulation that mints these sims:
148
+ [simocracy.org](https://simocracy.org)
149
+ - **pi** — Mario Zechner's terminal coding agent that hosts the
150
+ extension: [`@mariozechner/pi-coding-agent`](https://github.com/mariozechner/pi-coding-agent)
151
+ - **OpenTUI experiments** — earlier prototype that spawned a separate
152
+ Bun + OpenTUI window with an animated walking-cat scene. Removed in
153
+ favour of the inline ANSI render. The git history still has it if
154
+ you want the animated version back.
155
+
156
+ ---
157
+
158
+ ## License
159
+
160
+ MIT — see [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "pi-simocracy",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: load a Simocracy sim into your chat — see its pixel-art sprite render inline in the terminal and roleplay with it.",
5
+ "type": "module",
6
+ "author": "David Dao <david@gainforest.earth> (https://github.com/daviddao)",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "pi-extension",
13
+ "extension",
14
+ "simocracy",
15
+ "atproto",
16
+ "pixel-art",
17
+ "ansi",
18
+ "roleplay"
19
+ ],
20
+ "homepage": "https://github.com/GainForest/pi-simocracy#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/GainForest/pi-simocracy.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/GainForest/pi-simocracy/issues"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "src/",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "peerDependencies": {
42
+ "@mariozechner/pi-coding-agent": ">=0.58.0",
43
+ "@mariozechner/pi-tui": ">=0.58.0"
44
+ },
45
+ "dependencies": {
46
+ "pngjs": "^7.0.0",
47
+ "typebox": "^1.1.24"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^24.3.0",
51
+ "@types/pngjs": "^6.0.5",
52
+ "typescript": "^5.7.3"
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,569 @@
1
+ /**
2
+ * pi-simocracy — load a Simocracy sim into your pi chat.
3
+ *
4
+ * - `/sim <name>` Load a sim by name (fuzzy search on the indexer).
5
+ * Renders the sim's sprite as colored ANSI art directly
6
+ * in the chat and pushes its constitution + speaking
7
+ * style into the system prompt so pi roleplays as the
8
+ * sim.
9
+ * - `/sim unload` Drop the loaded sim and stop roleplaying.
10
+ * - `/sim status` Show the currently loaded sim, if any.
11
+ *
12
+ * Tools (LLM-callable):
13
+ * - `simocracy_load_sim` Same as /sim <name>.
14
+ * - `simocracy_unload_sim` Same as /sim unload.
15
+ * - `simocracy_chat` One-shot chat with a sim via OpenRouter (does
16
+ * not change the active session persona).
17
+ */
18
+
19
+ import type {
20
+ ExtensionAPI,
21
+ ExtensionContext,
22
+ ExtensionCommandContext,
23
+ } from "@mariozechner/pi-coding-agent";
24
+ import { Text } from "@mariozechner/pi-tui";
25
+ import { Type } from "typebox";
26
+
27
+ import {
28
+ searchSimsByName,
29
+ fetchAgentsForSim,
30
+ fetchStyleForSim,
31
+ fetchBlob,
32
+ resolveHandle,
33
+ parseAtUri,
34
+ type AgentsRecord,
35
+ type SimMatch,
36
+ type StyleRecord,
37
+ } from "./simocracy.ts";
38
+ import { decodePng, renderRgbaToAnsi, cropRgba } from "./png-to-ansi.ts";
39
+ import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // State
43
+ // ---------------------------------------------------------------------------
44
+
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
+ let loadedSim: LoadedSim | null = null;
59
+ /**
60
+ * Name of the most recently unloaded sim, if any. Cleared after the next
61
+ * agent turn fires — used to inject a one-shot “stop roleplaying” override
62
+ * into the system prompt so the model breaks character even though its
63
+ * previous in-character replies are still in the conversation history.
64
+ */
65
+ let justUnloaded: string | null = null;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function blobLink(ref: unknown): string | null {
72
+ if (ref && typeof ref === "object" && "$link" in (ref as Record<string, unknown>)) {
73
+ const l = (ref as { $link?: unknown }).$link;
74
+ if (typeof l === "string") return l;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Render a sim's sprite at its native 32×32 size as colored ANSI
81
+ * half-block art — 16 cells tall, 32 cells wide. Compact enough to fit
82
+ * comfortably in a terminal alongside the loaded-sim message.
83
+ *
84
+ * Pulls the front-facing walk1 frame (row 0, col 0) from the 128×128
85
+ * sprite-sheet blob. Falls back to the static avatar PNG if no sheet
86
+ * is published for this sim.
87
+ */
88
+ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
89
+ const spriteLink = blobLink(sim.sim.sprite?.ref);
90
+ const imageLink = blobLink(sim.sim.image?.ref);
91
+
92
+ if (spriteLink) {
93
+ try {
94
+ const buf = await fetchBlob(sim.did, spriteLink);
95
+ const { width, height, data } = decodePng(buf);
96
+ const FRAME = 32;
97
+ if (width >= FRAME && height >= FRAME) {
98
+ // Sheets are 4×4 of 32×32 frames — row 0 col 0 = front-facing walk1.
99
+ const frame = cropRgba(data, width, height, 0, 0, FRAME, FRAME);
100
+ return renderRgbaToAnsi(frame, FRAME, FRAME, {
101
+ cropToContent: true,
102
+ cropPad: 1,
103
+ indent: 2,
104
+ alphaThreshold: 16,
105
+ });
106
+ }
107
+ return renderRgbaToAnsi(data, width, height, {
108
+ cropToContent: true,
109
+ cropPad: 1,
110
+ indent: 2,
111
+ alphaThreshold: 16,
112
+ });
113
+ } catch {
114
+ /* fall through to avatar */
115
+ }
116
+ }
117
+
118
+ if (imageLink) {
119
+ try {
120
+ const buf = await fetchBlob(sim.did, imageLink);
121
+ const { width, height, data } = decodePng(buf);
122
+ return renderRgbaToAnsi(data, width, height, {
123
+ cropToContent: true,
124
+ cropPad: 1,
125
+ indent: 2,
126
+ alphaThreshold: 16,
127
+ });
128
+ } catch {
129
+ /* fall through */
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
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
+ async function loadSimByName(query: string): Promise<{
171
+ matches: SimMatch[];
172
+ loaded?: LoadedSim;
173
+ error?: string;
174
+ }> {
175
+ let matches: SimMatch[];
176
+ try {
177
+ matches = await searchSimsByName(query, { maxResults: 8 });
178
+ } catch (err) {
179
+ return { matches: [], error: `Indexer search failed: ${(err as Error).message}` };
180
+ }
181
+ if (matches.length === 0) {
182
+ return { matches: [], error: `No sim found matching "${query}".` };
183
+ }
184
+ return { matches };
185
+ }
186
+
187
+ async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
188
+ // Fetch agents (constitution), style, sprite ANSI + handle in parallel.
189
+ const [agents, style, spriteAnsi, handle] = await Promise.all([
190
+ fetchAgentsForSim(match.uri).catch(() => null) as Promise<AgentsRecord | null>,
191
+ fetchStyleForSim(match.uri).catch(() => null) as Promise<StyleRecord | null>,
192
+ renderSpriteAnsi(match).catch(() => null),
193
+ resolveHandle(match.did).catch(() => null),
194
+ ]);
195
+
196
+ return {
197
+ uri: match.uri,
198
+ did: match.did,
199
+ rkey: match.rkey,
200
+ name: match.sim.name,
201
+ handle,
202
+ spriteAnsi: spriteAnsi ?? undefined,
203
+ shortDescription: agents?.shortDescription,
204
+ description: agents?.description,
205
+ style: style?.description,
206
+ };
207
+ }
208
+
209
+ function formatSimSummary(
210
+ sim: LoadedSim,
211
+ theme?: ExtensionContext["ui"]["theme"],
212
+ ): string {
213
+ const dim = theme?.fg("dim", "") ? (s: string) => theme.fg("dim", s) : (s: string) => s;
214
+ const accent = theme?.fg("accent", "")
215
+ ? (s: string) => theme.fg("accent", s)
216
+ : (s: string) => s;
217
+ const lines: string[] = [];
218
+ if (sim.spriteAnsi) {
219
+ lines.push(sim.spriteAnsi);
220
+ lines.push("");
221
+ }
222
+ lines.push(` 🐾 ${accent(sim.name)}${sim.handle ? dim(` @${sim.handle}`) : ""} loaded—pi is now in character.`);
223
+ lines.push(dim(` ${sim.uri}`));
224
+ if (sim.shortDescription) {
225
+ lines.push("");
226
+ lines.push(" " + sim.shortDescription.split("\n").join("\n "));
227
+ }
228
+ return lines.join("\n");
229
+ }
230
+
231
+ // The OpenTUI standalone animated viewer used to live here. It now ships
232
+ // alongside this file as `viewer.ts` for anyone who wants the full-window
233
+ // experience — run it manually with:
234
+ //
235
+ // bun src/viewer.ts /tmp/pi-simocracy/<rkey>.json
236
+ //
237
+ // The default `/sim` flow renders inline ANSI art instead, so pi keeps the
238
+ // terminal it's already running in.
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Extension
242
+ // ---------------------------------------------------------------------------
243
+
244
+ const LoadSimToolParams = Type.Object({
245
+ query: Type.String({
246
+ description: "Sim name or AT-URI (at://did/org.simocracy.sim/rkey).",
247
+ minLength: 1,
248
+ }),
249
+ });
250
+
251
+ const ChatToolParams = Type.Object({
252
+ message: Type.String({ description: "Message to send to the sim.", minLength: 1 }),
253
+ query: Type.Optional(
254
+ Type.String({
255
+ description:
256
+ "Sim name to chat with. Defaults to the currently loaded sim if omitted.",
257
+ }),
258
+ ),
259
+ });
260
+
261
+ const UnloadToolParams = Type.Object({});
262
+
263
+ export default async function simocracy(pi: ExtensionAPI) {
264
+ // -------------------------------------------------------------------------
265
+ // System prompt injection — every turn the loaded sim's persona is appended.
266
+ // After an unload, a one-shot override fires on the very next turn to break
267
+ // character (otherwise the model imitates its own previous in-character
268
+ // replies that are still in the conversation history).
269
+ // -------------------------------------------------------------------------
270
+ pi.on("before_agent_start", async (event) => {
271
+ if (loadedSim) {
272
+ return {
273
+ systemPrompt: `${event.systemPrompt}\n\n${buildSimPrompt(loadedSim)}`,
274
+ };
275
+ }
276
+ if (justUnloaded) {
277
+ const formerName = justUnloaded;
278
+ justUnloaded = null;
279
+ const override = [
280
+ ``,
281
+ `# Roleplay ended`,
282
+ `You were previously roleplaying as **${formerName}**, a Simocracy sim. That roleplay session has ended.`,
283
+ `Drop the persona completely. Stop using ${formerName}'s speaking style, mannerisms, catchphrases, emoji, or vocabulary.`,
284
+ `Resume your default behavior as pi, a coding assistant. Speak in your normal neutral voice from now on.`,
285
+ `Earlier turns in this conversation will contain in-character replies from when ${formerName} was loaded — ignore that style; do not continue it.`,
286
+ ].join("\n");
287
+ return { systemPrompt: `${event.systemPrompt}${override}` };
288
+ }
289
+ return;
290
+ });
291
+
292
+ // -------------------------------------------------------------------------
293
+ // Custom message renderer — shows the sprite + bio inline in the chat.
294
+ // -------------------------------------------------------------------------
295
+ pi.registerMessageRenderer<{ body: string }>("simocracy_sim_loaded", (message) => {
296
+ const body =
297
+ (message.details as { body?: string } | undefined)?.body ??
298
+ (typeof message.content === "string" ? message.content : "");
299
+ return new Text(body, 0, 0);
300
+ });
301
+
302
+ // -------------------------------------------------------------------------
303
+ // Slash command: /sim
304
+ // -------------------------------------------------------------------------
305
+ pi.registerCommand("sim", {
306
+ description: "Load a Simocracy sim into your chat (or `/sim unload`, `/sim status`).",
307
+ handler: async (args, ctx) => {
308
+ const arg = args.trim();
309
+ if (!arg || arg === "help" || arg === "--help") {
310
+ ctx.ui.notify(
311
+ "Usage: /sim <name> load a sim (e.g. /sim mr meow)\n" +
312
+ " /sim unload stop roleplaying\n" +
313
+ " /sim status show currently loaded sim",
314
+ "info",
315
+ );
316
+ return;
317
+ }
318
+ if (arg === "unload" || arg === "clear") {
319
+ if (!loadedSim) {
320
+ ctx.ui.notify("No sim loaded.", "info");
321
+ return;
322
+ }
323
+ const name = loadedSim.name;
324
+ loadedSim = null;
325
+ justUnloaded = name;
326
+ ctx.ui.setStatus("simocracy", undefined);
327
+ ctx.ui.setWidget("simocracy", undefined);
328
+ ctx.ui.notify(`Unloaded ${name}. Pi will break character on the next reply.`, "info");
329
+ return;
330
+ }
331
+ if (arg === "status") {
332
+ if (!loadedSim) {
333
+ ctx.ui.notify("No sim loaded. Try `/sim mr meow`.", "info");
334
+ return;
335
+ }
336
+ await postSimToChat(pi, ctx, loadedSim, /*reload=*/ false);
337
+ return;
338
+ }
339
+ await runLoadFlow(pi, ctx, arg);
340
+ },
341
+ });
342
+
343
+ // -------------------------------------------------------------------------
344
+ // Tool: simocracy_load_sim
345
+ // -------------------------------------------------------------------------
346
+ pi.registerTool({
347
+ name: "simocracy_load_sim",
348
+ label: "Load Simocracy sim",
349
+ description:
350
+ "Load a Simocracy sim by name into the current pi session. Pi will stay in character as that sim until simocracy_unload_sim is called. Renders the sim's sprite in the terminal and injects the sim's constitution + speaking style into the system prompt.",
351
+ parameters: LoadSimToolParams,
352
+ async execute(_id, { query }, _signal, _onUpdate, ctx) {
353
+ const sim = await tryLoadFromQuery(query);
354
+ if (!sim) {
355
+ throw new Error(`No sim found matching "${query}".`);
356
+ }
357
+ loadedSim = sim;
358
+ if (ctx.hasUI) {
359
+ await postSimToChat(pi, ctx, sim, /*reload=*/ true);
360
+ }
361
+ const summary = [
362
+ `Loaded sim: ${sim.name}${sim.handle ? ` (@${sim.handle})` : ""}`,
363
+ `URI: ${sim.uri}`,
364
+ sim.shortDescription ? `\nShort description:\n${sim.shortDescription}` : "",
365
+ sim.description ? `\nConstitution:\n${sim.description}` : "",
366
+ sim.style ? `\nSpeaking style:\n${sim.style}` : "",
367
+ `\nFrom now on, stay in character as ${sim.name}.`,
368
+ ]
369
+ .filter(Boolean)
370
+ .join("\n");
371
+ return {
372
+ content: [{ type: "text" as const, text: summary }],
373
+ details: { uri: sim.uri, did: sim.did, rkey: sim.rkey, name: sim.name },
374
+ };
375
+ },
376
+ });
377
+
378
+ // -------------------------------------------------------------------------
379
+ // Tool: simocracy_unload_sim
380
+ // -------------------------------------------------------------------------
381
+ pi.registerTool({
382
+ name: "simocracy_unload_sim",
383
+ label: "Unload Simocracy sim",
384
+ description:
385
+ "Stop roleplaying as the currently loaded Simocracy sim. After this call, pi reverts to its default behavior.",
386
+ parameters: UnloadToolParams,
387
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
388
+ if (!loadedSim) {
389
+ return {
390
+ content: [{ type: "text" as const, text: "No sim loaded." }],
391
+ details: {},
392
+ };
393
+ }
394
+ const name = loadedSim.name;
395
+ loadedSim = null;
396
+ justUnloaded = name;
397
+ if (ctx.hasUI) {
398
+ ctx.ui.setStatus("simocracy", undefined);
399
+ ctx.ui.setWidget("simocracy", undefined);
400
+ }
401
+ return {
402
+ content: [
403
+ {
404
+ type: "text" as const,
405
+ text: `Unloaded ${name}. Drop the persona completely from your next reply onward — stop using their speaking style, mannerisms, emoji, or vocabulary. Speak in your default neutral voice.`,
406
+ },
407
+ ],
408
+ details: { unloaded: name },
409
+ };
410
+ },
411
+ });
412
+
413
+ // -------------------------------------------------------------------------
414
+ // Tool: simocracy_chat — one-shot, doesn't change session persona.
415
+ // -------------------------------------------------------------------------
416
+ pi.registerTool({
417
+ name: "simocracy_chat",
418
+ label: "Chat with Simocracy sim",
419
+ description:
420
+ "Send a single message to a Simocracy sim and return its response. Uses OpenRouter directly so it doesn't change the current pi session's persona. Useful for getting a sim's opinion as quoted text.",
421
+ parameters: ChatToolParams,
422
+ async execute(_id, { message, query }) {
423
+ let sim: LoadedSim | null = loadedSim;
424
+ if (query) {
425
+ sim = await tryLoadFromQuery(query);
426
+ }
427
+ if (!sim) {
428
+ throw new Error(
429
+ query
430
+ ? `No sim found matching "${query}".`
431
+ : "No sim loaded. Pass `query` or call simocracy_load_sim first.",
432
+ );
433
+ }
434
+ const messages: ChatMessage[] = [
435
+ { role: "system", content: buildSimPrompt(sim) },
436
+ { role: "user", content: message },
437
+ ];
438
+ const reply = await openRouterComplete(messages, { maxTokens: 600 });
439
+ return {
440
+ content: [{ type: "text" as const, text: `${sim.name} says:\n\n${reply}` }],
441
+ details: { name: sim.name, uri: sim.uri },
442
+ };
443
+ },
444
+ });
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Slash-command flow
449
+ // ---------------------------------------------------------------------------
450
+
451
+ async function runLoadFlow(
452
+ pi: ExtensionAPI,
453
+ ctx: ExtensionCommandContext,
454
+ arg: string,
455
+ ): Promise<void> {
456
+ ctx.ui.notify(`Searching for "${arg}"…`, "info");
457
+ let matches: SimMatch[] = [];
458
+ if (arg.startsWith("at://")) {
459
+ // AT-URI shortcut — fetch directly.
460
+ try {
461
+ const sim = await tryLoadFromQuery(arg);
462
+ if (sim) {
463
+ loadedSim = sim;
464
+ await postSimToChat(pi, ctx, sim, true);
465
+ return;
466
+ }
467
+ } catch {
468
+ /* fall through to search */
469
+ }
470
+ }
471
+ try {
472
+ const result = await loadSimByName(arg);
473
+ matches = result.matches;
474
+ if (result.error) {
475
+ ctx.ui.notify(result.error, "error");
476
+ return;
477
+ }
478
+ } catch (err) {
479
+ ctx.ui.notify(`Search failed: ${(err as Error).message}`, "error");
480
+ return;
481
+ }
482
+ let chosen = matches[0];
483
+ if (matches.length > 1) {
484
+ const labels = matches.map((m) => `${m.sim.name} — ${m.uri}`);
485
+ const picked = await ctx.ui.select(`Multiple matches for "${arg}"`, labels);
486
+ if (!picked) {
487
+ ctx.ui.notify("Cancelled.", "info");
488
+ return;
489
+ }
490
+ chosen = matches[labels.indexOf(picked)];
491
+ }
492
+ ctx.ui.notify(`Loading ${chosen.sim.name}…`, "info");
493
+ let sim: LoadedSim;
494
+ try {
495
+ sim = await hydrateLoadedSim(chosen);
496
+ } catch (err) {
497
+ ctx.ui.notify(`Failed to load sim: ${(err as Error).message}`, "error");
498
+ return;
499
+ }
500
+ loadedSim = sim;
501
+ await postSimToChat(pi, ctx, sim, true);
502
+ }
503
+
504
+ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
505
+ const trimmed = query.trim();
506
+ if (!trimmed) return null;
507
+ if (trimmed.startsWith("at://")) {
508
+ try {
509
+ const { did, rkey } = parseAtUri(trimmed);
510
+ // Fetch the sim record from the PDS so we have the blob refs.
511
+ const { getRecordFromPds } = await import("./simocracy.ts");
512
+ const sim = await getRecordFromPds<{
513
+ name: string;
514
+ image?: { ref: unknown };
515
+ sprite?: { ref: unknown };
516
+ $type?: string;
517
+ }>(did, "org.simocracy.sim", rkey);
518
+ const match: SimMatch = {
519
+ uri: trimmed,
520
+ cid: "",
521
+ did,
522
+ rkey,
523
+ sim: {
524
+ $type: "org.simocracy.sim",
525
+ name: sim.name,
526
+ settings: { selectedOptions: {} },
527
+ image: sim.image as never,
528
+ sprite: sim.sprite as never,
529
+ createdAt: "",
530
+ },
531
+ };
532
+ return await hydrateLoadedSim(match);
533
+ } catch {
534
+ return null;
535
+ }
536
+ }
537
+ const result = await loadSimByName(trimmed);
538
+ if (!result.matches.length) return null;
539
+ return await hydrateLoadedSim(result.matches[0]);
540
+ }
541
+
542
+ async function postSimToChat(
543
+ pi: ExtensionAPI,
544
+ ctx: ExtensionContext,
545
+ sim: LoadedSim,
546
+ _reload: boolean,
547
+ ) {
548
+ ctx.ui.setStatus("simocracy", `🐾 ${sim.name}`);
549
+ const headerLines = [`Simocracy: ${sim.name}${sim.handle ? ` (@${sim.handle})` : ""}`];
550
+ ctx.ui.setWidget("simocracy", headerLines, { placement: "aboveEditor" });
551
+ const body = formatSimSummary(sim, ctx.ui.theme);
552
+ pi.sendMessage({
553
+ customType: "simocracy_sim_loaded",
554
+ content: stripAnsiForLog(body),
555
+ display: true,
556
+ details: {
557
+ uri: sim.uri,
558
+ did: sim.did,
559
+ rkey: sim.rkey,
560
+ name: sim.name,
561
+ body,
562
+ },
563
+ });
564
+ }
565
+
566
+ /** Strip ANSI escapes for the textual log copy (the renderer uses details.body). */
567
+ function stripAnsiForLog(text: string): string {
568
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
569
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Minimal OpenRouter chat client used as a fallback for the `simocracy_chat`
3
+ * tool when the user wants to talk to a sim through pi without injecting a
4
+ * persona into the agent's system prompt.
5
+ *
6
+ * Reads OPENROUTER_API_KEY from the environment.
7
+ */
8
+
9
+ const DEFAULT_MODEL = "google/gemini-2.5-flash-lite";
10
+
11
+ export interface ChatMessage {
12
+ role: "system" | "user" | "assistant";
13
+ content: string;
14
+ }
15
+
16
+ export async function openRouterComplete(
17
+ messages: ChatMessage[],
18
+ opts: { model?: string; maxTokens?: number; temperature?: number; apiKey?: string } = {},
19
+ ): Promise<string> {
20
+ const apiKey = opts.apiKey ?? process.env.OPENROUTER_API_KEY;
21
+ if (!apiKey) {
22
+ throw new Error(
23
+ "OPENROUTER_API_KEY is not set. Export it or run with `OPENROUTER_API_KEY=... pi`.",
24
+ );
25
+ }
26
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ Authorization: `Bearer ${apiKey}`,
31
+ "HTTP-Referer": "https://simocracy.org",
32
+ "X-Title": "pi-simocracy",
33
+ },
34
+ body: JSON.stringify({
35
+ model: opts.model ?? DEFAULT_MODEL,
36
+ messages,
37
+ max_tokens: opts.maxTokens ?? 800,
38
+ temperature: opts.temperature ?? 0.85,
39
+ stream: false,
40
+ }),
41
+ });
42
+ if (!res.ok) {
43
+ const text = await res.text().catch(() => "");
44
+ throw new Error(`OpenRouter ${res.status}: ${text.slice(0, 400)}`);
45
+ }
46
+ const json = (await res.json()) as {
47
+ choices?: Array<{ message?: { content?: string } }>;
48
+ };
49
+ const content = json.choices?.[0]?.message?.content;
50
+ if (!content) throw new Error("OpenRouter returned no content");
51
+ return content;
52
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Render a PNG buffer as ANSI true-color terminal art using half-block characters.
3
+ *
4
+ * Each terminal cell renders 2 vertical pixels: top half via foreground color,
5
+ * bottom half via background color of the `▀` (upper half block) character.
6
+ *
7
+ * Transparent pixels (alpha < threshold) are emitted as a "default" cell so the
8
+ * terminal background shows through.
9
+ */
10
+
11
+ import { PNG } from "pngjs";
12
+
13
+ export interface RenderOptions {
14
+ /** Crop to the non-transparent bounding box first (default: true). */
15
+ cropToContent?: boolean;
16
+ /** Optional padding in pixels around the cropped region. */
17
+ cropPad?: number;
18
+ /** Indent each line by this many spaces (default: 2). */
19
+ indent?: number;
20
+ /** Alpha cutoff for "transparent" (0–255, default: 16). */
21
+ alphaThreshold?: number;
22
+ }
23
+
24
+ interface RGBA {
25
+ r: number;
26
+ g: number;
27
+ b: number;
28
+ a: number;
29
+ }
30
+
31
+ /** Decode a PNG buffer into a flat RGBA8 byte array + width/height. */
32
+ export function decodePng(buf: Buffer): { width: number; height: number; data: Buffer } {
33
+ const png = PNG.sync.read(buf);
34
+ // pngjs always normalises to RGBA8.
35
+ return { width: png.width, height: png.height, data: png.data };
36
+ }
37
+
38
+ function pixelAt(data: Buffer, width: number, x: number, y: number): RGBA {
39
+ const i = (y * width + x) * 4;
40
+ return { r: data[i], g: data[i + 1], b: data[i + 2], a: data[i + 3] };
41
+ }
42
+
43
+ function findBoundingBox(
44
+ data: Buffer,
45
+ width: number,
46
+ height: number,
47
+ alphaThreshold: number,
48
+ ): { x0: number; y0: number; x1: number; y1: number } {
49
+ let x0 = width,
50
+ y0 = height,
51
+ x1 = -1,
52
+ y1 = -1;
53
+ for (let y = 0; y < height; y++) {
54
+ for (let x = 0; x < width; x++) {
55
+ const a = data[(y * width + x) * 4 + 3];
56
+ if (a > alphaThreshold) {
57
+ if (x < x0) x0 = x;
58
+ if (y < y0) y0 = y;
59
+ if (x > x1) x1 = x;
60
+ if (y > y1) y1 = y;
61
+ }
62
+ }
63
+ }
64
+ if (x1 < 0) {
65
+ return { x0: 0, y0: 0, x1: width - 1, y1: height - 1 };
66
+ }
67
+ return { x0, y0, x1, y1 };
68
+ }
69
+
70
+ /** Render an RGBA region of a buffer to ANSI half-block art. */
71
+ export function renderRgbaToAnsi(
72
+ data: Buffer,
73
+ width: number,
74
+ height: number,
75
+ opts: RenderOptions = {},
76
+ ): string {
77
+ const cropToContent = opts.cropToContent !== false;
78
+ const pad = opts.cropPad ?? 1;
79
+ const indent = " ".repeat(opts.indent ?? 2);
80
+ const alphaThreshold = opts.alphaThreshold ?? 16;
81
+
82
+ let x0 = 0,
83
+ y0 = 0,
84
+ x1 = width - 1,
85
+ y1 = height - 1;
86
+ if (cropToContent) {
87
+ const bb = findBoundingBox(data, width, height, alphaThreshold);
88
+ x0 = Math.max(0, bb.x0 - pad);
89
+ y0 = Math.max(0, bb.y0 - pad);
90
+ x1 = Math.min(width - 1, bb.x1 + pad);
91
+ y1 = Math.min(height - 1, bb.y1 + pad);
92
+ }
93
+
94
+ const w = x1 - x0 + 1;
95
+ const h = y1 - y0 + 1;
96
+
97
+ // Process two pixel rows per terminal line.
98
+ const lines: string[] = [];
99
+ const RESET = "\x1b[0m";
100
+
101
+ for (let row = 0; row < h; row += 2) {
102
+ let line = indent;
103
+ let lastFg: string | null = null;
104
+ let lastBg: string | null = null;
105
+ for (let col = 0; col < w; col++) {
106
+ const top = pixelAt(data, width, x0 + col, y0 + row);
107
+ const bottom =
108
+ row + 1 < h ? pixelAt(data, width, x0 + col, y0 + row + 1) : { r: 0, g: 0, b: 0, a: 0 };
109
+ const topVisible = top.a > alphaThreshold;
110
+ const bottomVisible = bottom.a > alphaThreshold;
111
+
112
+ if (!topVisible && !bottomVisible) {
113
+ // Both transparent — terminator + space lets background show through.
114
+ if (lastFg !== null || lastBg !== null) {
115
+ line += RESET;
116
+ lastFg = null;
117
+ lastBg = null;
118
+ }
119
+ line += " ";
120
+ continue;
121
+ }
122
+
123
+ if (topVisible && bottomVisible) {
124
+ // Use ▀: fg = top, bg = bottom
125
+ const fg = `\x1b[38;2;${top.r};${top.g};${top.b}m`;
126
+ const bg = `\x1b[48;2;${bottom.r};${bottom.g};${bottom.b}m`;
127
+ if (fg !== lastFg) {
128
+ line += fg;
129
+ lastFg = fg;
130
+ }
131
+ if (bg !== lastBg) {
132
+ line += bg;
133
+ lastBg = bg;
134
+ }
135
+ line += "▀";
136
+ } else if (topVisible && !bottomVisible) {
137
+ // Use ▀ with default bg
138
+ if (lastBg !== null) {
139
+ line += "\x1b[49m";
140
+ lastBg = null;
141
+ }
142
+ const fg = `\x1b[38;2;${top.r};${top.g};${top.b}m`;
143
+ if (fg !== lastFg) {
144
+ line += fg;
145
+ lastFg = fg;
146
+ }
147
+ line += "▀";
148
+ } else {
149
+ // bottomVisible only — use ▄ with default bg
150
+ if (lastBg !== null) {
151
+ line += "\x1b[49m";
152
+ lastBg = null;
153
+ }
154
+ const fg = `\x1b[38;2;${bottom.r};${bottom.g};${bottom.b}m`;
155
+ if (fg !== lastFg) {
156
+ line += fg;
157
+ lastFg = fg;
158
+ }
159
+ line += "▄";
160
+ }
161
+ }
162
+ line += RESET;
163
+ lines.push(line);
164
+ }
165
+
166
+ return lines.join("\n");
167
+ }
168
+
169
+ /** Convenience: decode a PNG buffer and render to ANSI art. */
170
+ export function pngToAnsi(buf: Buffer, opts: RenderOptions = {}): string {
171
+ const { width, height, data } = decodePng(buf);
172
+ return renderRgbaToAnsi(data, width, height, opts);
173
+ }
174
+
175
+ /** Extract a sub-region of an RGBA buffer (returns a fresh buffer). */
176
+ export function cropRgba(
177
+ data: Buffer,
178
+ width: number,
179
+ _height: number,
180
+ x: number,
181
+ y: number,
182
+ w: number,
183
+ h: number,
184
+ ): Buffer {
185
+ const out = Buffer.alloc(w * h * 4);
186
+ for (let row = 0; row < h; row++) {
187
+ const srcStart = ((y + row) * width + x) * 4;
188
+ const dstStart = row * w * 4;
189
+ data.copy(out, dstStart, srcStart, srcStart + w * 4);
190
+ }
191
+ return out;
192
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Simocracy indexer + PDS client (read-only).
3
+ *
4
+ * - Searches sims by name on the Simocracy GraphQL indexer.
5
+ * - Falls back to the user's PDS if a record isn't reachable through the indexer.
6
+ * - Resolves blob URLs through the owning DID's PDS.
7
+ */
8
+
9
+ const DEFAULT_INDEXER_URL = "https://simocracy-indexer-production.up.railway.app";
10
+ const COLLECTION_SIM = "org.simocracy.sim";
11
+ const COLLECTION_AGENTS = "org.simocracy.agents";
12
+ const COLLECTION_STYLE = "org.simocracy.style";
13
+
14
+ export interface SpriteSettings {
15
+ selectedOptions: Record<string, string>;
16
+ partColorSettings?: Record<string, { red: number; green: number; blue: number; alpha: number }>;
17
+ currentAnimDirection?: number;
18
+ characterSet?: string;
19
+ }
20
+
21
+ export interface BlobRef {
22
+ ref: { $link: string } | unknown;
23
+ mimeType: string;
24
+ size: number;
25
+ }
26
+
27
+ export interface SimRecord {
28
+ $type: "org.simocracy.sim";
29
+ name: string;
30
+ settings: SpriteSettings;
31
+ image?: BlobRef;
32
+ sprite?: BlobRef;
33
+ createdAt: string;
34
+ }
35
+
36
+ export interface AgentsRecord {
37
+ $type: "org.simocracy.agents";
38
+ sim: { uri: string; cid: string };
39
+ shortDescription: string;
40
+ description?: string;
41
+ createdAt: string;
42
+ }
43
+
44
+ export interface StyleRecord {
45
+ $type?: "org.simocracy.style";
46
+ sim: { uri: string; cid: string };
47
+ description: string;
48
+ createdAt: string;
49
+ }
50
+
51
+ export interface SimMatch {
52
+ uri: string;
53
+ cid: string;
54
+ did: string;
55
+ rkey: string;
56
+ sim: SimRecord;
57
+ }
58
+
59
+ interface GraphQLNode {
60
+ uri: string;
61
+ cid: string;
62
+ did: string;
63
+ rkey: string;
64
+ collection: string;
65
+ value: Record<string, unknown>;
66
+ }
67
+
68
+ interface GraphQLResponse<T> {
69
+ data?: T;
70
+ errors?: Array<{ message: string }>;
71
+ }
72
+
73
+ const RECORDS_QUERY = `
74
+ query FetchRecords($collection: String!, $first: Int, $after: String) {
75
+ records(collection: $collection, first: $first, after: $after) {
76
+ edges {
77
+ node { uri cid did rkey collection value }
78
+ cursor
79
+ }
80
+ pageInfo { hasNextPage endCursor }
81
+ }
82
+ }
83
+ `;
84
+
85
+ async function fetchRecords(
86
+ collection: string,
87
+ first: number,
88
+ cursor: string | null,
89
+ indexerUrl: string,
90
+ ): Promise<{ nodes: GraphQLNode[]; hasNextPage: boolean; endCursor?: string }> {
91
+ const res = await fetch(`${indexerUrl.replace(/\/+$/, "")}/graphql`, {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({
95
+ query: RECORDS_QUERY,
96
+ variables: { collection, first, after: cursor },
97
+ }),
98
+ });
99
+ if (!res.ok) {
100
+ throw new Error(`Indexer returned ${res.status} for ${collection}`);
101
+ }
102
+ const json = (await res.json()) as GraphQLResponse<{
103
+ records: {
104
+ edges: Array<{ node: GraphQLNode }>;
105
+ pageInfo: { hasNextPage: boolean; endCursor?: string };
106
+ };
107
+ }>;
108
+ if (json.errors?.length) {
109
+ throw new Error(`Indexer GraphQL error: ${json.errors[0]?.message ?? "unknown"}`);
110
+ }
111
+ return {
112
+ nodes: json.data?.records.edges.map((e) => e.node) ?? [],
113
+ hasNextPage: json.data?.records.pageInfo.hasNextPage ?? false,
114
+ endCursor: json.data?.records.pageInfo.endCursor,
115
+ };
116
+ }
117
+
118
+ /** Score a sim against a query for ranking match quality. Lower = better match. */
119
+ function scoreSimAgainstQuery(simName: string, query: string): number {
120
+ const a = simName.toLowerCase().trim();
121
+ const b = query.toLowerCase().trim();
122
+ if (a === b) return 0;
123
+ if (a.replace(/\s+/g, "") === b.replace(/\s+/g, "")) return 1; // ignore whitespace
124
+ if (a.startsWith(b)) return 2;
125
+ if (a.includes(b)) return 3 + (a.length - b.length); // shorter wraps win
126
+ // Token overlap: each query token found inside sim name reduces score
127
+ const queryTokens = b.split(/\s+/).filter(Boolean);
128
+ const matched = queryTokens.filter((t) => a.includes(t)).length;
129
+ if (matched > 0) return 100 - matched;
130
+ return Number.POSITIVE_INFINITY;
131
+ }
132
+
133
+ /**
134
+ * Search the indexer for sims whose name matches the query.
135
+ * Returns up to `maxResults` matches sorted by match quality.
136
+ */
137
+ export async function searchSimsByName(
138
+ query: string,
139
+ opts: { indexerUrl?: string; maxResults?: number; pageSize?: number } = {},
140
+ ): Promise<SimMatch[]> {
141
+ const indexerUrl = opts.indexerUrl ?? DEFAULT_INDEXER_URL;
142
+ const maxResults = opts.maxResults ?? 10;
143
+ const pageSize = opts.pageSize ?? 200;
144
+
145
+ const matches: Array<SimMatch & { score: number }> = [];
146
+ let cursor: string | null = null;
147
+ // Cap pages — the indexer holds at most a few hundred sims today.
148
+ for (let page = 0; page < 10; page++) {
149
+ const { nodes, hasNextPage, endCursor } = await fetchRecords(
150
+ COLLECTION_SIM,
151
+ pageSize,
152
+ cursor,
153
+ indexerUrl,
154
+ );
155
+ for (const node of nodes) {
156
+ const sim = node.value as unknown as SimRecord;
157
+ if (!sim?.name) continue;
158
+ const score = scoreSimAgainstQuery(sim.name, query);
159
+ if (Number.isFinite(score)) {
160
+ matches.push({
161
+ uri: node.uri,
162
+ cid: node.cid,
163
+ did: node.did,
164
+ rkey: node.rkey,
165
+ sim,
166
+ score,
167
+ });
168
+ }
169
+ }
170
+ if (!hasNextPage || !endCursor) break;
171
+ cursor = endCursor;
172
+ }
173
+
174
+ matches.sort((a, b) => a.score - b.score);
175
+ return matches.slice(0, maxResults).map(({ score: _s, ...rest }) => rest);
176
+ }
177
+
178
+ interface ParsedAtUri {
179
+ did: string;
180
+ collection: string;
181
+ rkey: string;
182
+ }
183
+
184
+ export function parseAtUri(uri: string): ParsedAtUri {
185
+ const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
186
+ if (!m) throw new Error(`Invalid AT-URI: ${uri}`);
187
+ return { did: m[1], collection: m[2], rkey: m[3] };
188
+ }
189
+
190
+ /** Resolve a DID's PDS service endpoint via PLC directory or did:web well-known. */
191
+ export async function resolvePds(did: string): Promise<string> {
192
+ let url: string;
193
+ if (did.startsWith("did:plc:")) {
194
+ url = `https://plc.directory/${encodeURIComponent(did)}`;
195
+ } else if (did.startsWith("did:web:")) {
196
+ url = `https://${did.slice("did:web:".length)}/.well-known/did.json`;
197
+ } else {
198
+ throw new Error(`Unsupported DID method: ${did}`);
199
+ }
200
+ const res = await fetch(url);
201
+ if (!res.ok) throw new Error(`Resolve PDS for ${did} failed: ${res.status}`);
202
+ const doc = (await res.json()) as { service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }> };
203
+ const service = doc.service?.find(
204
+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
205
+ );
206
+ if (!service?.serviceEndpoint) throw new Error(`No PDS endpoint for ${did}`);
207
+ return service.serviceEndpoint;
208
+ }
209
+
210
+ /** Fetch a blob (e.g. avatar PNG) by following PDS redirects. */
211
+ export async function fetchBlob(did: string, cidLink: string): Promise<Buffer> {
212
+ const pds = await resolvePds(did);
213
+ const url = `${pds.replace(/\/+$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cidLink)}`;
214
+ const res = await fetch(url, { redirect: "follow" });
215
+ if (!res.ok) throw new Error(`Blob fetch failed: ${res.status} ${res.statusText}`);
216
+ const ab = await res.arrayBuffer();
217
+ return Buffer.from(ab);
218
+ }
219
+
220
+ /** Read a record directly from the owner's PDS. */
221
+ export async function getRecordFromPds<T>(did: string, collection: string, rkey: string): Promise<T> {
222
+ const pds = await resolvePds(did);
223
+ const url =
224
+ `${pds.replace(/\/+$/, "")}/xrpc/com.atproto.repo.getRecord` +
225
+ `?repo=${encodeURIComponent(did)}` +
226
+ `&collection=${encodeURIComponent(collection)}` +
227
+ `&rkey=${encodeURIComponent(rkey)}`;
228
+ const res = await fetch(url);
229
+ if (!res.ok) throw new Error(`PDS getRecord failed: ${res.status}`);
230
+ const json = (await res.json()) as { value?: unknown };
231
+ if (!json.value) throw new Error(`Record not found: ${url}`);
232
+ return json.value as T;
233
+ }
234
+
235
+ /** List records by paging com.atproto.repo.listRecords on a PDS. */
236
+ export async function listRecordsFromPds<T>(did: string, collection: string): Promise<Array<{ uri: string; cid: string; value: T }>> {
237
+ const pds = await resolvePds(did);
238
+ const out: Array<{ uri: string; cid: string; value: T }> = [];
239
+ let cursor: string | undefined;
240
+ for (let i = 0; i < 10; i++) {
241
+ const params = new URLSearchParams({ repo: did, collection, limit: "100" });
242
+ if (cursor) params.set("cursor", cursor);
243
+ const res = await fetch(`${pds.replace(/\/+$/, "")}/xrpc/com.atproto.repo.listRecords?${params}`);
244
+ if (!res.ok) throw new Error(`PDS listRecords failed: ${res.status}`);
245
+ const json = (await res.json()) as {
246
+ records?: Array<{ uri: string; cid: string; value: unknown }>;
247
+ cursor?: string;
248
+ };
249
+ for (const r of json.records ?? []) out.push({ uri: r.uri, cid: r.cid, value: r.value as T });
250
+ cursor = json.cursor;
251
+ if (!cursor) break;
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /** Find the agents record for a sim by scanning the owner's PDS (sim-1:1-agents). */
257
+ export async function fetchAgentsForSim(simUri: string): Promise<AgentsRecord | null> {
258
+ const { did } = parseAtUri(simUri);
259
+ try {
260
+ const records = await listRecordsFromPds<AgentsRecord>(did, COLLECTION_AGENTS);
261
+ return records.find((r) => r.value.sim?.uri === simUri)?.value ?? null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /** Find the style record for a sim by scanning the owner's PDS. */
268
+ export async function fetchStyleForSim(simUri: string): Promise<StyleRecord | null> {
269
+ const { did } = parseAtUri(simUri);
270
+ try {
271
+ const records = await listRecordsFromPds<StyleRecord>(did, COLLECTION_STYLE);
272
+ return records.find((r) => r.value.sim?.uri === simUri)?.value ?? null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ /** Resolve handle of a DID via Bluesky AppView (best-effort). */
279
+ export async function resolveHandle(did: string): Promise<string | null> {
280
+ try {
281
+ const res = await fetch(
282
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
283
+ );
284
+ if (!res.ok) return null;
285
+ const json = (await res.json()) as { handle?: string };
286
+ return json.handle ?? null;
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
292
+ export const SIMOCRACY_INDEXER_URL = DEFAULT_INDEXER_URL;