pi-simocracy 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -30
- package/package.json +2 -1
- package/src/auth/commands.ts +4 -4
- package/src/index.ts +361 -444
- package/src/openrouter.ts +0 -16
- package/src/png-to-ansi.ts +79 -0
- package/src/simocracy.ts +24 -90
- package/src/webp-to-rgba.ts +70 -0
- package/src/writes.ts +117 -53
- package/src/interview.ts +0 -516
- package/src/training/alignment.ts +0 -259
- package/src/training/apply.ts +0 -271
- package/src/training/baseline.ts +0 -159
- package/src/training/chat.ts +0 -131
- package/src/training/feedback.ts +0 -81
- package/src/training/index.ts +0 -142
- package/src/training/profile.ts +0 -229
- package/src/training/prompt-helpers.ts +0 -70
- package/src/training/prompts.ts +0 -131
- package/src/training/question-set.ts +0 -134
- package/src/training/storage.ts +0 -81
- package/src/training/types.ts +0 -121
package/src/openrouter.ts
CHANGED
|
@@ -8,22 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
const DEFAULT_MODEL = "google/gemini-2.5-flash-lite";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Chat model for the Training Lab + interview flows. Mirrors
|
|
13
|
-
* `DEFAULT_CHAT_MODEL` in `simocracy-v2/lib/openrouter.ts` — keep in
|
|
14
|
-
* sync. Override via `DEFAULT_CHAT_MODEL` env var.
|
|
15
|
-
*/
|
|
16
|
-
export const TRAINING_CHAT_MODEL =
|
|
17
|
-
process.env.DEFAULT_CHAT_MODEL ?? "google/gemini-3.1-flash-lite-preview";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Reasoning model used by the merge-constitution flow. Mirrors
|
|
21
|
-
* `DEFAULT_REASONING_MODEL` in `simocracy-v2/lib/openrouter.ts` —
|
|
22
|
-
* keep in sync. Override via `DEFAULT_REASONING_MODEL` env var.
|
|
23
|
-
*/
|
|
24
|
-
export const TRAINING_REASONING_MODEL =
|
|
25
|
-
process.env.DEFAULT_REASONING_MODEL ?? "~google/gemini-pro-latest";
|
|
26
|
-
|
|
27
11
|
export interface ChatMessage {
|
|
28
12
|
role: "system" | "user" | "assistant";
|
|
29
13
|
content: string;
|
package/src/png-to-ansi.ts
CHANGED
|
@@ -235,6 +235,85 @@ export function downscaleRgbaNearest(
|
|
|
235
235
|
return { data: out, width: newW, height: newH };
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Box-average downscale an RGBA buffer to arbitrary target dimensions.
|
|
240
|
+
*
|
|
241
|
+
* Uses straight area-weighted averaging on premultiplied alpha so partly
|
|
242
|
+
* transparent edge pixels don't bleed black into the result. Designed for
|
|
243
|
+
* non-pixel-art inputs (codex pet idle thumbnails, codex pet sheet cells)
|
|
244
|
+
* where we want a smaller display size at non-integer ratios.
|
|
245
|
+
*
|
|
246
|
+
* If the target equals the source size, returns the input untouched.
|
|
247
|
+
*/
|
|
248
|
+
export function boxDownscaleRgba(
|
|
249
|
+
data: Buffer,
|
|
250
|
+
width: number,
|
|
251
|
+
height: number,
|
|
252
|
+
targetW: number,
|
|
253
|
+
targetH: number,
|
|
254
|
+
): { data: Buffer; width: number; height: number } {
|
|
255
|
+
if (targetW < 1 || targetH < 1) {
|
|
256
|
+
throw new Error(`boxDownscaleRgba target must be ≥1×1, got ${targetW}×${targetH}`);
|
|
257
|
+
}
|
|
258
|
+
if (targetW === width && targetH === height) {
|
|
259
|
+
return { data, width, height };
|
|
260
|
+
}
|
|
261
|
+
const out = Buffer.alloc(targetW * targetH * 4);
|
|
262
|
+
// For each output pixel, average the source rectangle that maps to it.
|
|
263
|
+
// Edge cells are clamped to the source extent.
|
|
264
|
+
for (let oy = 0; oy < targetH; oy++) {
|
|
265
|
+
const sy0 = (oy * height) / targetH;
|
|
266
|
+
const sy1 = ((oy + 1) * height) / targetH;
|
|
267
|
+
const y0 = Math.floor(sy0);
|
|
268
|
+
const y1 = Math.min(height, Math.ceil(sy1));
|
|
269
|
+
for (let ox = 0; ox < targetW; ox++) {
|
|
270
|
+
const sx0 = (ox * width) / targetW;
|
|
271
|
+
const sx1 = ((ox + 1) * width) / targetW;
|
|
272
|
+
const x0 = Math.floor(sx0);
|
|
273
|
+
const x1 = Math.min(width, Math.ceil(sx1));
|
|
274
|
+
|
|
275
|
+
// Premultiplied accumulation so transparent pixels don't smear
|
|
276
|
+
// black RGB values into mostly-opaque neighbours.
|
|
277
|
+
let rSum = 0,
|
|
278
|
+
gSum = 0,
|
|
279
|
+
bSum = 0,
|
|
280
|
+
aSum = 0,
|
|
281
|
+
wSum = 0;
|
|
282
|
+
for (let y = y0; y < y1; y++) {
|
|
283
|
+
// Vertical coverage of this source row inside the output cell.
|
|
284
|
+
const wy = Math.min(y + 1, sy1) - Math.max(y, sy0);
|
|
285
|
+
if (wy <= 0) continue;
|
|
286
|
+
for (let x = x0; x < x1; x++) {
|
|
287
|
+
const wx = Math.min(x + 1, sx1) - Math.max(x, sx0);
|
|
288
|
+
if (wx <= 0) continue;
|
|
289
|
+
const w = wx * wy;
|
|
290
|
+
const i = (y * width + x) * 4;
|
|
291
|
+
const a = data[i + 3];
|
|
292
|
+
const aw = a * w;
|
|
293
|
+
rSum += data[i] * aw;
|
|
294
|
+
gSum += data[i + 1] * aw;
|
|
295
|
+
bSum += data[i + 2] * aw;
|
|
296
|
+
aSum += aw;
|
|
297
|
+
wSum += w;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const di = (oy * targetW + ox) * 4;
|
|
301
|
+
if (aSum > 0) {
|
|
302
|
+
out[di] = Math.min(255, Math.round(rSum / aSum));
|
|
303
|
+
out[di + 1] = Math.min(255, Math.round(gSum / aSum));
|
|
304
|
+
out[di + 2] = Math.min(255, Math.round(bSum / aSum));
|
|
305
|
+
out[di + 3] = Math.min(255, Math.round(aSum / wSum));
|
|
306
|
+
} else {
|
|
307
|
+
out[di] = 0;
|
|
308
|
+
out[di + 1] = 0;
|
|
309
|
+
out[di + 2] = 0;
|
|
310
|
+
out[di + 3] = 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { data: out, width: targetW, height: targetH };
|
|
315
|
+
}
|
|
316
|
+
|
|
238
317
|
/**
|
|
239
318
|
* Detect the integer upscale factor of a pixel-art image by scanning
|
|
240
319
|
* for the largest factor F where every F×F block has uniform colour.
|
package/src/simocracy.ts
CHANGED
|
@@ -10,7 +10,6 @@ const DEFAULT_INDEXER_URL = "https://simocracy-indexer-production.up.railway.app
|
|
|
10
10
|
const COLLECTION_SIM = "org.simocracy.sim";
|
|
11
11
|
const COLLECTION_AGENTS = "org.simocracy.agents";
|
|
12
12
|
const COLLECTION_STYLE = "org.simocracy.style";
|
|
13
|
-
const COLLECTION_INTERVIEW_TEMPLATE = "org.simocracy.interviewTemplate";
|
|
14
13
|
|
|
15
14
|
export interface SpriteSettings {
|
|
16
15
|
selectedOptions: Record<string, string>;
|
|
@@ -25,13 +24,33 @@ export interface BlobRef {
|
|
|
25
24
|
size: number;
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
/** Which sprite system a sim uses. Absent = legacy 'pipoya'. */
|
|
28
|
+
export type SpriteKind = "pipoya" | "codexPet";
|
|
29
|
+
|
|
30
|
+
/** Subset of pet.json from the OpenAI hatch-pet skill output. */
|
|
31
|
+
export interface CodexPetManifest {
|
|
32
|
+
id?: string;
|
|
33
|
+
displayName?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
export interface SimRecord {
|
|
29
38
|
$type: "org.simocracy.sim";
|
|
30
39
|
name: string;
|
|
31
|
-
|
|
40
|
+
/** Discriminator for sprite system. Absent = 'pipoya'. */
|
|
41
|
+
spriteKind?: SpriteKind;
|
|
42
|
+
/** Pipoya appearance settings (only used when spriteKind = 'pipoya'). */
|
|
43
|
+
settings?: SpriteSettings;
|
|
44
|
+
/** Rendered avatar PNG/JPEG/WebP thumbnail (always present for codex pets, generated client-side as a 128×128 PNG). */
|
|
32
45
|
image?: BlobRef;
|
|
46
|
+
/** Pipoya 4×4 walk sheet (128×128 PNG). Only present when spriteKind = 'pipoya'. */
|
|
33
47
|
sprite?: BlobRef;
|
|
48
|
+
/** Codex pet 8×9 atlas (1536×1872, 192×208 cells, PNG or WebP). Only present when spriteKind = 'codexPet'. */
|
|
49
|
+
petSheet?: BlobRef;
|
|
50
|
+
/** Subset of pet.json from the hatch-pet skill output. Only present when spriteKind = 'codexPet'. */
|
|
51
|
+
petManifest?: CodexPetManifest;
|
|
34
52
|
createdAt: string;
|
|
53
|
+
updatedAt?: string;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
export interface AgentsRecord {
|
|
@@ -307,94 +326,9 @@ export async function fetchStyleForSim(simUri: string): Promise<StyleRecord | nu
|
|
|
307
326
|
}
|
|
308
327
|
}
|
|
309
328
|
|
|
310
|
-
//
|
|
311
|
-
// Interview
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
export interface InterviewQuestionRecord {
|
|
315
|
-
id: string;
|
|
316
|
-
type: "open" | "text" | "yesNo";
|
|
317
|
-
prompt: string;
|
|
318
|
-
required?: boolean;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export interface InterviewTemplateValue {
|
|
322
|
-
$type: "org.simocracy.interviewTemplate";
|
|
323
|
-
name: string;
|
|
324
|
-
description?: string;
|
|
325
|
-
questions: InterviewQuestionRecord[];
|
|
326
|
-
createdAt: string;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export interface LoadedInterviewTemplate {
|
|
330
|
-
uri: string;
|
|
331
|
-
cid: string;
|
|
332
|
-
did: string;
|
|
333
|
-
rkey: string;
|
|
334
|
-
template: InterviewTemplateValue;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* List interview templates from the Simocracy indexer. Mirrors
|
|
339
|
-
* `fetchInterviewTemplates` in simocracy-v2's `lib/indexer.ts` (sans
|
|
340
|
-
* the PDS fallback against the facilitator — pi-simocracy doesn't
|
|
341
|
-
* know the facilitator DID, and an empty list is a soft failure
|
|
342
|
-
* handled by the caller via the built-in fallback template).
|
|
343
|
-
*/
|
|
344
|
-
export async function searchInterviewTemplates(
|
|
345
|
-
limit = 100,
|
|
346
|
-
opts: { indexerUrl?: string } = {},
|
|
347
|
-
): Promise<LoadedInterviewTemplate[]> {
|
|
348
|
-
const indexerUrl = opts.indexerUrl ?? DEFAULT_INDEXER_URL;
|
|
349
|
-
const results: LoadedInterviewTemplate[] = [];
|
|
350
|
-
let cursor: string | null = null;
|
|
351
|
-
for (let page = 0; page < 10 && results.length < limit; page++) {
|
|
352
|
-
const { nodes, hasNextPage, endCursor } = await fetchRecords(
|
|
353
|
-
COLLECTION_INTERVIEW_TEMPLATE,
|
|
354
|
-
Math.min(100, limit - results.length),
|
|
355
|
-
cursor,
|
|
356
|
-
indexerUrl,
|
|
357
|
-
);
|
|
358
|
-
for (const node of nodes) {
|
|
359
|
-
const value = node.value as unknown as InterviewTemplateValue;
|
|
360
|
-
if (!value?.name || !Array.isArray(value.questions)) continue;
|
|
361
|
-
results.push({
|
|
362
|
-
uri: node.uri,
|
|
363
|
-
cid: node.cid,
|
|
364
|
-
did: node.did,
|
|
365
|
-
rkey: node.rkey,
|
|
366
|
-
template: value,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
if (!hasNextPage || !endCursor) break;
|
|
370
|
-
cursor = endCursor;
|
|
371
|
-
}
|
|
372
|
-
return results;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Fetch a single interview template directly from the owner's PDS by
|
|
377
|
-
* AT-URI. Returns null on any failure — callers fall through to the
|
|
378
|
-
* built-in template.
|
|
379
|
-
*/
|
|
380
|
-
export async function fetchInterviewTemplateByUri(
|
|
381
|
-
templateUri: string,
|
|
382
|
-
): Promise<LoadedInterviewTemplate | null> {
|
|
383
|
-
try {
|
|
384
|
-
const { did, collection, rkey } = parseAtUri(templateUri);
|
|
385
|
-
if (collection !== COLLECTION_INTERVIEW_TEMPLATE) return null;
|
|
386
|
-
const value = await getRecordFromPds<InterviewTemplateValue>(did, collection, rkey);
|
|
387
|
-
return {
|
|
388
|
-
uri: templateUri,
|
|
389
|
-
cid: "",
|
|
390
|
-
did,
|
|
391
|
-
rkey,
|
|
392
|
-
template: value,
|
|
393
|
-
};
|
|
394
|
-
} catch {
|
|
395
|
-
return null;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
329
|
+
// (Interview-template fetchers were removed alongside the Training Lab /
|
|
330
|
+
// Interview Modal pipelines. The only remaining persona-edit path is the
|
|
331
|
+
// `simocracy_update_sim` LLM tool, which doesn't consume templates.)
|
|
398
332
|
|
|
399
333
|
/** Resolve handle of a DID via Bluesky AppView (best-effort). */
|
|
400
334
|
export async function resolveHandle(did: string): Promise<string | null> {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebP → RGBA decoder for Node.
|
|
3
|
+
*
|
|
4
|
+
* `pngjs` covers the PNG side of pi-simocracy, but the OpenAI hatch-pet
|
|
5
|
+
* skill (and therefore most `org.simocracy.sim` records with
|
|
6
|
+
* `spriteKind = "codexPet"`) ships its 1536×1872 atlas as WebP. We need
|
|
7
|
+
* a WebP decoder to render the idle frame.
|
|
8
|
+
*
|
|
9
|
+
* @jsquash/webp's `decode()` is browser-shaped: in Node its wasm glue
|
|
10
|
+
* tries to `fetch()` its own `.wasm` URL, which Undici rejects for
|
|
11
|
+
* `file://`. Workaround: read the wasm bytes from disk via
|
|
12
|
+
* `createRequire().resolve()`, compile to a `WebAssembly.Module`, and
|
|
13
|
+
* feed it to the package's documented `init()` escape hatch. Fully ESM,
|
|
14
|
+
* no native bindings.
|
|
15
|
+
*
|
|
16
|
+
* The wasm module is initialised lazily on the first `decodeWebp()`
|
|
17
|
+
* call so loading pi-simocracy stays cheap when no one ever loads a
|
|
18
|
+
* codex pet sim. Subsequent calls reuse the same module.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import decode, { init as initWebpDecode } from "@jsquash/webp/decode.js";
|
|
24
|
+
|
|
25
|
+
let wasmInitPromise: Promise<void> | null = null;
|
|
26
|
+
|
|
27
|
+
async function ensureWasmInit(): Promise<void> {
|
|
28
|
+
if (wasmInitPromise) return wasmInitPromise;
|
|
29
|
+
wasmInitPromise = (async () => {
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
// @jsquash/webp ships the decoder wasm next to its JS glue; the
|
|
32
|
+
// package only re-exports JS files, so we have to resolve the wasm
|
|
33
|
+
// path manually. Resolving against the JS entry guarantees we hit
|
|
34
|
+
// the same install of the package the bundler/linker picked.
|
|
35
|
+
const decodeJs = require.resolve("@jsquash/webp/decode.js");
|
|
36
|
+
const wasmPath = decodeJs.replace(/decode\.js$/, "codec/dec/webp_dec.wasm");
|
|
37
|
+
const bytes = await readFile(wasmPath);
|
|
38
|
+
const mod = await WebAssembly.compile(bytes);
|
|
39
|
+
await initWebpDecode(mod);
|
|
40
|
+
})().catch((err) => {
|
|
41
|
+
// Reset so the next caller can retry; otherwise a transient FS
|
|
42
|
+
// error would permanently break codex-pet rendering for the session.
|
|
43
|
+
wasmInitPromise = null;
|
|
44
|
+
throw new Error(`Failed to init @jsquash/webp wasm: ${(err as Error).message}`);
|
|
45
|
+
});
|
|
46
|
+
return wasmInitPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode a WebP buffer into the same flat-RGBA shape `decodePng()`
|
|
51
|
+
* returns, so call sites can treat the two interchangeably.
|
|
52
|
+
*
|
|
53
|
+
* Allocates a fresh `Buffer` rather than aliasing `Uint8ClampedArray`
|
|
54
|
+
* so downstream `Buffer`-only helpers (`pixelAt`, `cropRgba`,
|
|
55
|
+
* `boxDownscaleRgba`) keep working without coercion.
|
|
56
|
+
*/
|
|
57
|
+
export async function decodeWebp(
|
|
58
|
+
buf: Buffer,
|
|
59
|
+
): Promise<{ width: number; height: number; data: Buffer }> {
|
|
60
|
+
await ensureWasmInit();
|
|
61
|
+
// jSquash wants an ArrayBuffer view of just the relevant bytes —
|
|
62
|
+
// passing the whole underlying buffer mis-decodes if `buf` is a slice.
|
|
63
|
+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
64
|
+
const img = await decode(ab as ArrayBuffer);
|
|
65
|
+
return {
|
|
66
|
+
width: img.width,
|
|
67
|
+
height: img.height,
|
|
68
|
+
data: Buffer.from(img.data.buffer, img.data.byteOffset, img.data.byteLength),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/writes.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PDS writes via the authenticated OAuth session.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - org.simocracy.agents
|
|
7
|
-
* - org.simocracy.style
|
|
4
|
+
* Two record types are written here, both 1:1 with a sim and either
|
|
5
|
+
* created (first time) or put-overwritten (subsequent edits):
|
|
6
|
+
* - org.simocracy.agents — short description + constitution body
|
|
7
|
+
* - org.simocracy.style — speaking style description
|
|
8
|
+
*
|
|
9
|
+
* The questionnaire-driven `org.simocracy.interview` write path was
|
|
10
|
+
* removed when the structured Training Lab + Interview flows were
|
|
11
|
+
* dropped from this extension; constitution edits are now made
|
|
12
|
+
* directly via `simocracy_update_sim` (an LLM-callable tool the
|
|
13
|
+
* coding agent invokes after chatting with the user about how to
|
|
14
|
+
* refine the loaded sim).
|
|
8
15
|
*
|
|
9
16
|
* `getAuthenticatedAgent()` restores the session via the OAuth
|
|
10
17
|
* client's session store and returns an `Agent` from `@atproto/api`,
|
|
@@ -15,7 +22,8 @@
|
|
|
15
22
|
import { Agent } from "@atproto/api";
|
|
16
23
|
|
|
17
24
|
import { getOAuthClient } from "./auth/oauth.ts";
|
|
18
|
-
import { readAuth } from "./auth/storage.ts";
|
|
25
|
+
import { readAuth, type AuthRecord } from "./auth/storage.ts";
|
|
26
|
+
import { resolveHandle } from "./simocracy.ts";
|
|
19
27
|
|
|
20
28
|
export class NotSignedInError extends Error {
|
|
21
29
|
constructor(message = "Not signed into ATProto. Run `/sim login <handle>` first (e.g. `/sim login alice.bsky.social`). This is separate from pi's built-in `/login` (Anthropic).") {
|
|
@@ -24,6 +32,78 @@ export class NotSignedInError extends Error {
|
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Thrown when the signed-in DID does not own the sim that's about to
|
|
37
|
+
* be written to. The `simocracy.org` webapp owns the public lexicon
|
|
38
|
+
* surface, but per-sim records (`org.simocracy.agents`,
|
|
39
|
+
* `org.simocracy.style`) live in the *owner's* PDS — there's no
|
|
40
|
+
* shared repo. Without this guard a signed-in user could only ever
|
|
41
|
+
* write to their own repo anyway (the PDS rejects writes to other
|
|
42
|
+
* DIDs), but the failure would surface as a confusing XRPC 401 from
|
|
43
|
+
* the PDS at the moment of the call. This class lets the
|
|
44
|
+
* `simocracy_update_sim` tool fail fast with a human-readable
|
|
45
|
+
* message *before* it touches the network.
|
|
46
|
+
*/
|
|
47
|
+
export class NotSimOwnerError extends Error {
|
|
48
|
+
readonly ownerDid: string;
|
|
49
|
+
readonly ownerHandle: string | null;
|
|
50
|
+
readonly signedInDid: string;
|
|
51
|
+
readonly signedInHandle: string | null;
|
|
52
|
+
constructor(opts: {
|
|
53
|
+
ownerDid: string;
|
|
54
|
+
ownerHandle: string | null;
|
|
55
|
+
signedInDid: string;
|
|
56
|
+
signedInHandle: string | null;
|
|
57
|
+
action?: string;
|
|
58
|
+
}) {
|
|
59
|
+
const ownerLabel = opts.ownerHandle ? `@${opts.ownerHandle}` : opts.ownerDid;
|
|
60
|
+
const meLabel = opts.signedInHandle ? `@${opts.signedInHandle}` : opts.signedInDid;
|
|
61
|
+
const action = opts.action ?? "write to";
|
|
62
|
+
super(
|
|
63
|
+
`You can only ${action} sims you own. Loaded sim is owned by ${ownerLabel} — your signed-in DID is ${meLabel}.`,
|
|
64
|
+
);
|
|
65
|
+
this.name = "NotSimOwnerError";
|
|
66
|
+
this.ownerDid = opts.ownerDid;
|
|
67
|
+
this.ownerHandle = opts.ownerHandle;
|
|
68
|
+
this.signedInDid = opts.signedInDid;
|
|
69
|
+
this.signedInHandle = opts.signedInHandle;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Precondition for the write path: must be signed in *and* the
|
|
75
|
+
* signed-in DID must match the loaded sim's owner DID. Resolves the
|
|
76
|
+
* sim owner's handle on a best-effort basis so the error message is
|
|
77
|
+
* legible. Throws `NotSignedInError` or `NotSimOwnerError` — never
|
|
78
|
+
* returns falsy. Called by `simocracy_update_sim` (the tool entry
|
|
79
|
+
* point) before any XRPC traffic, and again at each call site in
|
|
80
|
+
* this module as defense-in-depth via `assertRepoOwnsSimUri`.
|
|
81
|
+
*/
|
|
82
|
+
export async function assertCanWriteToSim(loadedSim: {
|
|
83
|
+
did: string;
|
|
84
|
+
handle: string | null;
|
|
85
|
+
}, opts: { action?: string } = {}): Promise<AuthRecord> {
|
|
86
|
+
const auth = readAuth();
|
|
87
|
+
if (!auth) {
|
|
88
|
+
const action = opts.action ?? "write to a sim";
|
|
89
|
+
throw new NotSignedInError(
|
|
90
|
+
`Not signed into ATProto — can't ${action}. Run \`/sim login <handle>\` first (e.g. \`/sim login alice.bsky.social\`). This is separate from pi's built-in \`/login\` (Anthropic).`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (auth.did !== loadedSim.did) {
|
|
94
|
+
const ownerHandle =
|
|
95
|
+
loadedSim.handle ?? (await resolveHandle(loadedSim.did).catch(() => null));
|
|
96
|
+
throw new NotSimOwnerError({
|
|
97
|
+
ownerDid: loadedSim.did,
|
|
98
|
+
ownerHandle,
|
|
99
|
+
signedInDid: auth.did,
|
|
100
|
+
signedInHandle: auth.handle,
|
|
101
|
+
action: opts.action,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return auth;
|
|
105
|
+
}
|
|
106
|
+
|
|
27
107
|
export async function getAuthenticatedAgent(): Promise<{ agent: Agent; did: string }> {
|
|
28
108
|
const auth = readAuth();
|
|
29
109
|
if (!auth) throw new NotSignedInError();
|
|
@@ -45,60 +125,40 @@ export async function getAuthenticatedAgent(): Promise<{ agent: Agent; did: stri
|
|
|
45
125
|
return { agent, did: auth.did };
|
|
46
126
|
}
|
|
47
127
|
|
|
48
|
-
const COLLECTION_INTERVIEW = "org.simocracy.interview";
|
|
49
128
|
const COLLECTION_AGENTS = "org.simocracy.agents";
|
|
50
129
|
const COLLECTION_STYLE = "org.simocracy.style";
|
|
51
130
|
|
|
52
|
-
interface OpenAnswer {
|
|
53
|
-
questionId?: string;
|
|
54
|
-
question: string;
|
|
55
|
-
answer: string;
|
|
56
|
-
}
|
|
57
|
-
interface YesNoAnswer {
|
|
58
|
-
questionId?: string;
|
|
59
|
-
statement: string;
|
|
60
|
-
answer: boolean;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
131
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
132
|
+
* Defense-in-depth: every write helper below verifies the target
|
|
133
|
+
* `repo` (which we always set to the signed-in DID) matches the
|
|
134
|
+
* sim's owner DID parsed out of the AT-URI. This prevents a future
|
|
135
|
+
* caller from accidentally passing the wrong `did` and writing
|
|
136
|
+
* orphaned per-sim records into the user's own repo that point at a
|
|
137
|
+
* sim they don't own. Throws `NotSimOwnerError` synchronously — the
|
|
138
|
+
* tool entry-point already checks up-front via
|
|
139
|
+
* `assertCanWriteToSim`, this is the belt-and-braces version that
|
|
140
|
+
* runs at the actual XRPC call site.
|
|
66
141
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
questionId: a.questionId,
|
|
87
|
-
statement: a.statement,
|
|
88
|
-
answer: a.answer,
|
|
89
|
-
})),
|
|
90
|
-
createdAt: new Date().toISOString(),
|
|
91
|
-
};
|
|
92
|
-
if (opts.templateUri && opts.templateCid) {
|
|
93
|
-
record.template = { uri: opts.templateUri, cid: opts.templateCid };
|
|
142
|
+
function assertRepoOwnsSimUri(did: string, simUri: string): void {
|
|
143
|
+
// simUri is at://<owner-did>/org.simocracy.sim/<rkey>; if the
|
|
144
|
+
// string didn't come from parseAtUri we still fall back to a string
|
|
145
|
+
// prefix check so this stays a pure precondition without re-fetching.
|
|
146
|
+
const owner = simUri.startsWith("at://")
|
|
147
|
+
? simUri.slice("at://".length).split("/")[0]
|
|
148
|
+
: "";
|
|
149
|
+
if (!owner) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Refusing to write: sim AT-URI "${simUri}" is not in at://<did>/<collection>/<rkey> form.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (owner !== did) {
|
|
155
|
+
throw new NotSimOwnerError({
|
|
156
|
+
ownerDid: owner,
|
|
157
|
+
ownerHandle: null,
|
|
158
|
+
signedInDid: did,
|
|
159
|
+
signedInHandle: null,
|
|
160
|
+
});
|
|
94
161
|
}
|
|
95
|
-
|
|
96
|
-
const res = await opts.agent.com.atproto.repo.createRecord({
|
|
97
|
-
repo: opts.did,
|
|
98
|
-
collection: COLLECTION_INTERVIEW,
|
|
99
|
-
record,
|
|
100
|
-
});
|
|
101
|
-
return { uri: res.data.uri, cid: res.data.cid };
|
|
102
162
|
}
|
|
103
163
|
|
|
104
164
|
/**
|
|
@@ -116,6 +176,7 @@ export async function createAgents(opts: {
|
|
|
116
176
|
shortDescription: string;
|
|
117
177
|
description: string;
|
|
118
178
|
}): Promise<{ uri: string; cid: string; rkey: string }> {
|
|
179
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
119
180
|
const record = {
|
|
120
181
|
$type: COLLECTION_AGENTS,
|
|
121
182
|
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
@@ -148,6 +209,7 @@ export async function updateAgents(opts: {
|
|
|
148
209
|
shortDescription: string;
|
|
149
210
|
description: string;
|
|
150
211
|
}): Promise<{ uri: string; cid: string }> {
|
|
212
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
151
213
|
const record = {
|
|
152
214
|
$type: COLLECTION_AGENTS,
|
|
153
215
|
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
@@ -171,6 +233,7 @@ export async function createStyle(opts: {
|
|
|
171
233
|
simCid: string;
|
|
172
234
|
description: string;
|
|
173
235
|
}): Promise<{ uri: string; cid: string; rkey: string }> {
|
|
236
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
174
237
|
const record = {
|
|
175
238
|
$type: COLLECTION_STYLE,
|
|
176
239
|
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
@@ -197,6 +260,7 @@ export async function updateStyle(opts: {
|
|
|
197
260
|
simCid: string;
|
|
198
261
|
description: string;
|
|
199
262
|
}): Promise<{ uri: string; cid: string }> {
|
|
263
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
200
264
|
const record = {
|
|
201
265
|
$type: COLLECTION_STYLE,
|
|
202
266
|
sim: { uri: opts.simUri, cid: opts.simCid },
|