pi-simocracy 0.2.0 → 0.3.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/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/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>;
@@ -307,94 +306,9 @@ export async function fetchStyleForSim(simUri: string): Promise<StyleRecord | nu
307
306
  }
308
307
  }
309
308
 
310
- // ---------------------------------------------------------------------------
311
- // Interview templates (org.simocracy.interviewTemplate)
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
- }
309
+ // (Interview-template fetchers were removed alongside the Training Lab /
310
+ // Interview Modal pipelines. The only remaining persona-edit path is the
311
+ // `simocracy_update_sim` LLM tool, which doesn't consume templates.)
398
312
 
399
313
  /** Resolve handle of a DID via Bluesky AppView (best-effort). */
400
314
  export async function resolveHandle(did: string): Promise<string | null> {
package/src/writes.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * PDS writes via the authenticated OAuth session.
3
3
  *
4
- * Three record types are written here:
5
- * - org.simocracy.interview (one per Apply, append-only)
6
- * - org.simocracy.agents (1:1 with sim create or update)
7
- * - org.simocracy.style (1:1 with sim create or update)
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
- * POST `org.simocracy.interview`. Mirrors `interview-modal.tsx`'s
65
- * save payload with optional template StrongRef.
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
- export async function createInterview(opts: {
68
- agent: Agent;
69
- did: string;
70
- simUri: string;
71
- simCid: string;
72
- openAnswers: OpenAnswer[];
73
- yesNoAnswers: YesNoAnswer[];
74
- templateUri?: string;
75
- templateCid?: string;
76
- }): Promise<{ uri: string; cid: string }> {
77
- const record: Record<string, unknown> = {
78
- $type: COLLECTION_INTERVIEW,
79
- sim: { uri: opts.simUri, cid: opts.simCid },
80
- openAnswers: opts.openAnswers.map((a) => ({
81
- questionId: a.questionId,
82
- question: a.question,
83
- answer: a.answer,
84
- })),
85
- yesNoAnswers: opts.yesNoAnswers.map((a) => ({
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 },