pi-simocracy 0.1.1 → 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/README.md +49 -14
- 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 +480 -64
- package/src/persona.ts +60 -0
- package/src/simocracy.ts +35 -0
- package/src/writes.ts +309 -0
package/src/simocracy.ts
CHANGED
|
@@ -253,6 +253,37 @@ export async function listRecordsFromPds<T>(did: string, collection: string): Pr
|
|
|
253
253
|
return out;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* List every `org.simocracy.sim` record owned by `did`, mapped onto the
|
|
258
|
+
* same `SimMatch` shape that `searchSimsByName` produces so the rest of
|
|
259
|
+
* the extension's load/hydrate pipeline accepts these without a second
|
|
260
|
+
* code path. Sorted by `createdAt` descending (most recently created
|
|
261
|
+
* first), since that's how simocracy.org's My Sims carousel surfaces
|
|
262
|
+
* them and it's the most useful ordering when the user types `/sim my 1`.
|
|
263
|
+
*/
|
|
264
|
+
export async function fetchSimsForDid(did: string): Promise<SimMatch[]> {
|
|
265
|
+
const records = await listRecordsFromPds<SimRecord>(did, COLLECTION_SIM);
|
|
266
|
+
return records
|
|
267
|
+
.filter((r) => r.value && typeof r.value.name === "string")
|
|
268
|
+
.map((r) => {
|
|
269
|
+
const rkey = r.uri.split("/").pop() ?? "";
|
|
270
|
+
return {
|
|
271
|
+
uri: r.uri,
|
|
272
|
+
cid: r.cid,
|
|
273
|
+
did,
|
|
274
|
+
rkey,
|
|
275
|
+
sim: r.value,
|
|
276
|
+
} satisfies SimMatch;
|
|
277
|
+
})
|
|
278
|
+
.sort((a, b) => {
|
|
279
|
+
// Most recent first; fall back to rkey (TIDs are roughly monotonic).
|
|
280
|
+
const ta = a.sim.createdAt || "";
|
|
281
|
+
const tb = b.sim.createdAt || "";
|
|
282
|
+
if (ta && tb) return tb.localeCompare(ta);
|
|
283
|
+
return b.rkey.localeCompare(a.rkey);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
256
287
|
/** Find the agents record for a sim by scanning the owner's PDS (sim-1:1-agents). */
|
|
257
288
|
export async function fetchAgentsForSim(simUri: string): Promise<AgentsRecord | null> {
|
|
258
289
|
const { did } = parseAtUri(simUri);
|
|
@@ -275,6 +306,10 @@ export async function fetchStyleForSim(simUri: string): Promise<StyleRecord | nu
|
|
|
275
306
|
}
|
|
276
307
|
}
|
|
277
308
|
|
|
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.)
|
|
312
|
+
|
|
278
313
|
/** Resolve handle of a DID via Bluesky AppView (best-effort). */
|
|
279
314
|
export async function resolveHandle(did: string): Promise<string | null> {
|
|
280
315
|
try {
|
package/src/writes.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDS writes via the authenticated OAuth session.
|
|
3
|
+
*
|
|
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).
|
|
15
|
+
*
|
|
16
|
+
* `getAuthenticatedAgent()` restores the session via the OAuth
|
|
17
|
+
* client's session store and returns an `Agent` from `@atproto/api`,
|
|
18
|
+
* which exposes the same `com.atproto.repo.*` XRPC methods we'd use
|
|
19
|
+
* with an app-password agent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Agent } from "@atproto/api";
|
|
23
|
+
|
|
24
|
+
import { getOAuthClient } from "./auth/oauth.ts";
|
|
25
|
+
import { readAuth, type AuthRecord } from "./auth/storage.ts";
|
|
26
|
+
import { resolveHandle } from "./simocracy.ts";
|
|
27
|
+
|
|
28
|
+
export class NotSignedInError extends Error {
|
|
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).") {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "NotSignedInError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
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
|
+
|
|
107
|
+
export async function getAuthenticatedAgent(): Promise<{ agent: Agent; did: string }> {
|
|
108
|
+
const auth = readAuth();
|
|
109
|
+
if (!auth) throw new NotSignedInError();
|
|
110
|
+
const client = getOAuthClient();
|
|
111
|
+
// refresh="auto" — the OAuth client refreshes the access token if
|
|
112
|
+
// it's about to expire and persists the new tokens via the session
|
|
113
|
+
// store. If refresh fails (e.g. revoked, expired refresh token),
|
|
114
|
+
// restore() throws; we surface it as a NotSignedInError so callers
|
|
115
|
+
// get a consistent shape.
|
|
116
|
+
let oauthSession;
|
|
117
|
+
try {
|
|
118
|
+
oauthSession = await client.restore(auth.did);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new NotSignedInError(
|
|
121
|
+
`Stored ATProto session for ${auth.did} could not be restored — please run /sim login again. (${(err as Error).message})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const agent = new Agent(oauthSession);
|
|
125
|
+
return { agent, did: auth.did };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const COLLECTION_AGENTS = "org.simocracy.agents";
|
|
129
|
+
const COLLECTION_STYLE = "org.simocracy.style";
|
|
130
|
+
|
|
131
|
+
/**
|
|
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.
|
|
141
|
+
*/
|
|
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
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* POST `org.simocracy.agents` (constitution + short description).
|
|
166
|
+
* Plain text only — no facets in PR 3 to keep dep count down. The web
|
|
167
|
+
* app keeps the markdown variant as a follow-up; readers handle a
|
|
168
|
+
* facet-less record fine since the lexicon's `descriptionFacets` is
|
|
169
|
+
* optional.
|
|
170
|
+
*/
|
|
171
|
+
export async function createAgents(opts: {
|
|
172
|
+
agent: Agent;
|
|
173
|
+
did: string;
|
|
174
|
+
simUri: string;
|
|
175
|
+
simCid: string;
|
|
176
|
+
shortDescription: string;
|
|
177
|
+
description: string;
|
|
178
|
+
}): Promise<{ uri: string; cid: string; rkey: string }> {
|
|
179
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
180
|
+
const record = {
|
|
181
|
+
$type: COLLECTION_AGENTS,
|
|
182
|
+
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
183
|
+
shortDescription: opts.shortDescription.slice(0, 300),
|
|
184
|
+
description: opts.description,
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
};
|
|
187
|
+
const res = await opts.agent.com.atproto.repo.createRecord({
|
|
188
|
+
repo: opts.did,
|
|
189
|
+
collection: COLLECTION_AGENTS,
|
|
190
|
+
record,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
uri: res.data.uri,
|
|
194
|
+
cid: res.data.cid,
|
|
195
|
+
rkey: res.data.uri.split("/").pop() ?? "",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* PUT `org.simocracy.agents` at a known rkey. Used when the sim
|
|
201
|
+
* already has an agents record we want to overwrite.
|
|
202
|
+
*/
|
|
203
|
+
export async function updateAgents(opts: {
|
|
204
|
+
agent: Agent;
|
|
205
|
+
did: string;
|
|
206
|
+
rkey: string;
|
|
207
|
+
simUri: string;
|
|
208
|
+
simCid: string;
|
|
209
|
+
shortDescription: string;
|
|
210
|
+
description: string;
|
|
211
|
+
}): Promise<{ uri: string; cid: string }> {
|
|
212
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
213
|
+
const record = {
|
|
214
|
+
$type: COLLECTION_AGENTS,
|
|
215
|
+
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
216
|
+
shortDescription: opts.shortDescription.slice(0, 300),
|
|
217
|
+
description: opts.description,
|
|
218
|
+
createdAt: new Date().toISOString(),
|
|
219
|
+
};
|
|
220
|
+
const res = await opts.agent.com.atproto.repo.putRecord({
|
|
221
|
+
repo: opts.did,
|
|
222
|
+
collection: COLLECTION_AGENTS,
|
|
223
|
+
rkey: opts.rkey,
|
|
224
|
+
record,
|
|
225
|
+
});
|
|
226
|
+
return { uri: res.data.uri, cid: res.data.cid };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function createStyle(opts: {
|
|
230
|
+
agent: Agent;
|
|
231
|
+
did: string;
|
|
232
|
+
simUri: string;
|
|
233
|
+
simCid: string;
|
|
234
|
+
description: string;
|
|
235
|
+
}): Promise<{ uri: string; cid: string; rkey: string }> {
|
|
236
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
237
|
+
const record = {
|
|
238
|
+
$type: COLLECTION_STYLE,
|
|
239
|
+
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
240
|
+
description: opts.description,
|
|
241
|
+
createdAt: new Date().toISOString(),
|
|
242
|
+
};
|
|
243
|
+
const res = await opts.agent.com.atproto.repo.createRecord({
|
|
244
|
+
repo: opts.did,
|
|
245
|
+
collection: COLLECTION_STYLE,
|
|
246
|
+
record,
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
uri: res.data.uri,
|
|
250
|
+
cid: res.data.cid,
|
|
251
|
+
rkey: res.data.uri.split("/").pop() ?? "",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function updateStyle(opts: {
|
|
256
|
+
agent: Agent;
|
|
257
|
+
did: string;
|
|
258
|
+
rkey: string;
|
|
259
|
+
simUri: string;
|
|
260
|
+
simCid: string;
|
|
261
|
+
description: string;
|
|
262
|
+
}): Promise<{ uri: string; cid: string }> {
|
|
263
|
+
assertRepoOwnsSimUri(opts.did, opts.simUri);
|
|
264
|
+
const record = {
|
|
265
|
+
$type: COLLECTION_STYLE,
|
|
266
|
+
sim: { uri: opts.simUri, cid: opts.simCid },
|
|
267
|
+
description: opts.description,
|
|
268
|
+
createdAt: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
const res = await opts.agent.com.atproto.repo.putRecord({
|
|
271
|
+
repo: opts.did,
|
|
272
|
+
collection: COLLECTION_STYLE,
|
|
273
|
+
rkey: opts.rkey,
|
|
274
|
+
record,
|
|
275
|
+
});
|
|
276
|
+
return { uri: res.data.uri, cid: res.data.cid };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Best-effort lookup of an existing rkey by listing the collection
|
|
281
|
+
* and finding the record whose `sim.uri` matches. Used by the Apply
|
|
282
|
+
* paths to decide between create vs update.
|
|
283
|
+
*/
|
|
284
|
+
export async function findRkeyForSim(
|
|
285
|
+
agent: Agent,
|
|
286
|
+
did: string,
|
|
287
|
+
collection: string,
|
|
288
|
+
simUri: string,
|
|
289
|
+
): Promise<string | null> {
|
|
290
|
+
let cursor: string | undefined;
|
|
291
|
+
for (let page = 0; page < 10; page++) {
|
|
292
|
+
const res = await agent.com.atproto.repo.listRecords({
|
|
293
|
+
repo: did,
|
|
294
|
+
collection,
|
|
295
|
+
limit: 100,
|
|
296
|
+
cursor,
|
|
297
|
+
});
|
|
298
|
+
for (const rec of res.data.records) {
|
|
299
|
+
const value = rec.value as { sim?: { uri?: string } };
|
|
300
|
+
if (value?.sim?.uri === simUri) {
|
|
301
|
+
const rkey = rec.uri.split("/").pop();
|
|
302
|
+
if (rkey) return rkey;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
cursor = res.data.cursor;
|
|
306
|
+
if (!cursor) break;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|