pi-simocracy 0.5.1 → 0.6.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/src/writes.ts CHANGED
@@ -127,6 +127,9 @@ export async function getAuthenticatedAgent(): Promise<{ agent: Agent; did: stri
127
127
 
128
128
  const COLLECTION_AGENTS = "org.simocracy.agents";
129
129
  const COLLECTION_STYLE = "org.simocracy.style";
130
+ const COLLECTION_COMMENT = "org.impactindexer.review.comment";
131
+ const COLLECTION_HISTORY = "org.simocracy.history";
132
+ const COLLECTION_PROPOSAL = "org.hypercerts.claim.activity";
130
133
 
131
134
  /**
132
135
  * Defense-in-depth: every write helper below verifies the target
@@ -276,6 +279,240 @@ export async function updateStyle(opts: {
276
279
  return { uri: res.data.uri, cid: res.data.cid };
277
280
  }
278
281
 
282
+ /**
283
+ * POST `org.impactindexer.review.comment`.
284
+ *
285
+ * Matches the wire shape simocracy.org's `useRecordComments.postComment`
286
+ * already writes today (`subject = { uri, type: 'record' }`, no CID), so
287
+ * comments authored from pi render identically in the webapp and thread
288
+ * correctly under the same parent. The `subject.uri` is the parent
289
+ * record — proposal, gathering, sim, decision, or another comment for
290
+ * a nested reply.
291
+ *
292
+ * No sim-attribution lives in this record. Sim attribution is a
293
+ * sidecar `org.simocracy.history` written by `createCommentHistory`
294
+ * below — see `docs/SIM_AUTHORED_COMMENTS.md` for the full design.
295
+ */
296
+ export async function createComment(opts: {
297
+ agent: Agent;
298
+ did: string;
299
+ subjectUri: string;
300
+ text: string;
301
+ }): Promise<{ uri: string; cid: string; rkey: string }> {
302
+ const trimmed = opts.text.trim();
303
+ if (!trimmed) {
304
+ throw new Error("Cannot post an empty comment.");
305
+ }
306
+ const record = {
307
+ $type: COLLECTION_COMMENT,
308
+ subject: { uri: opts.subjectUri, type: "record" },
309
+ text: trimmed.slice(0, 5000),
310
+ createdAt: new Date().toISOString(),
311
+ };
312
+ const res = await opts.agent.com.atproto.repo.createRecord({
313
+ repo: opts.did,
314
+ collection: COLLECTION_COMMENT,
315
+ record,
316
+ });
317
+ return {
318
+ uri: res.data.uri,
319
+ cid: res.data.cid,
320
+ rkey: res.data.uri.split("/").pop() ?? "",
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Sim-attribution sidecar for a comment.
326
+ *
327
+ * The `org.impactindexer.review.comment` lexicon has no field for
328
+ * "this comment is the voice of sim X" — and we don't want to extend
329
+ * an impactindexer-owned lexicon for a Simocracy-specific concept.
330
+ * Instead we use Simocracy's existing `org.simocracy.history`
331
+ * lexicon as a join table:
332
+ *
333
+ * commentUri ←— history.subjectUri
334
+ * loaded sim ←— history.simUris[0] / simNames[0]
335
+ *
336
+ * Renderers that understand this pattern (simocracy.org) join the two
337
+ * sets at display time and show the comment with a sim badge;
338
+ * renderers that don't (Bluesky AppView, third-party clients) fall
339
+ * back to displaying the comment as a regular user comment — graceful
340
+ * degradation, zero lexicon changes anywhere.
341
+ *
342
+ * Writes to the *user's* own PDS (the comment author), not a shared
343
+ * facilitator repo, because the attribution is an event the user
344
+ * triggered and naturally belongs in their history.
345
+ */
346
+ export async function createCommentHistory(opts: {
347
+ agent: Agent;
348
+ did: string;
349
+ commentUri: string;
350
+ simUri: string;
351
+ simName: string;
352
+ text: string;
353
+ /** Title of the parent record (proposal / gathering / sim) — best-effort. */
354
+ proposalTitle?: string;
355
+ /** Collection of the parent record — best-effort. */
356
+ parentCollection?: string;
357
+ /** Human-readable name of the parent — best-effort, denormalized for the timeline. */
358
+ parentName?: string;
359
+ }): Promise<{ uri: string; cid: string; rkey: string }> {
360
+ // Defense-in-depth: the sim must live in the same repo we're writing to.
361
+ // (If it doesn't, the indexer ingests a history record claiming attribution
362
+ // for a sim the actor doesn't own — confusing rather than dangerous, but
363
+ // worth catching here.)
364
+ assertRepoOwnsSimUri(opts.did, opts.simUri);
365
+ const record: Record<string, unknown> = {
366
+ $type: COLLECTION_HISTORY,
367
+ type: "comment",
368
+ actorDid: opts.did,
369
+ simNames: [opts.simName].slice(0, 10),
370
+ simUris: [opts.simUri].slice(0, 10),
371
+ subjectUri: opts.commentUri,
372
+ subjectCollection: COLLECTION_COMMENT,
373
+ content: opts.text.slice(0, 5000),
374
+ createdAt: new Date().toISOString(),
375
+ };
376
+ if (opts.proposalTitle) {
377
+ record.proposalTitle = opts.proposalTitle.slice(0, 500);
378
+ }
379
+ if (opts.parentName) {
380
+ record.subjectName = opts.parentName.slice(0, 500);
381
+ }
382
+ const res = await opts.agent.com.atproto.repo.createRecord({
383
+ repo: opts.did,
384
+ collection: COLLECTION_HISTORY,
385
+ record,
386
+ });
387
+ return {
388
+ uri: res.data.uri,
389
+ cid: res.data.cid,
390
+ rkey: res.data.uri.split("/").pop() ?? "",
391
+ };
392
+ }
393
+
394
+ /**
395
+ * POST `org.hypercerts.claim.activity` (a funding proposal).
396
+ *
397
+ * Matches the wire shape simocracy.org's `ProposalFormDialog` writes
398
+ * today (`title`, `shortDescription`, optional `description` /
399
+ * `workScope` / `contributors` / `image`, `createdAt`), so proposals
400
+ * authored from pi render identically in the webapp.
401
+ *
402
+ * No sim-attribution lives in this record. Sim attribution is a
403
+ * sidecar `org.simocracy.history` written by `createProposalHistory`
404
+ * below — same pattern as comments, see
405
+ * `docs/SIM_AUTHORED_PROPOSALS.md` for the design rationale.
406
+ *
407
+ * The proposal itself is the *user's*, not the sim's, so this writer
408
+ * does NOT call `assertRepoOwnsSimUri` — the only precondition is
409
+ * that the user is signed in (enforced at the tool entry point via
410
+ * `assertCanWriteToSim`, which also requires a loaded sim because
411
+ * attribution requires one).
412
+ */
413
+ export async function createProposal(opts: {
414
+ agent: Agent;
415
+ did: string;
416
+ title: string;
417
+ shortDescription: string;
418
+ description?: string;
419
+ workScope?: string;
420
+ contributors?: Array<{ contributorIdentity: string }>;
421
+ image?: { $type: "org.hypercerts.defs#uri"; uri: string };
422
+ }): Promise<{ uri: string; cid: string; rkey: string }> {
423
+ const title = opts.title.trim();
424
+ if (!title) throw new Error("Proposal title is required.");
425
+ const shortDescription = opts.shortDescription.trim();
426
+ if (!shortDescription)
427
+ throw new Error("Proposal shortDescription is required.");
428
+ const record: Record<string, unknown> = {
429
+ $type: COLLECTION_PROPOSAL,
430
+ title: title.slice(0, 256),
431
+ shortDescription: shortDescription.slice(0, 300),
432
+ createdAt: new Date().toISOString(),
433
+ };
434
+ if (opts.description !== undefined) {
435
+ const body = opts.description.trim();
436
+ if (body) record.description = body;
437
+ }
438
+ if (opts.workScope !== undefined) {
439
+ const ws = opts.workScope.trim();
440
+ if (ws) record.workScope = ws;
441
+ }
442
+ if (opts.contributors && opts.contributors.length > 0) {
443
+ record.contributors = opts.contributors;
444
+ }
445
+ if (opts.image) record.image = opts.image;
446
+ const res = await opts.agent.com.atproto.repo.createRecord({
447
+ repo: opts.did,
448
+ collection: COLLECTION_PROPOSAL,
449
+ record,
450
+ });
451
+ return {
452
+ uri: res.data.uri,
453
+ cid: res.data.cid,
454
+ rkey: res.data.uri.split("/").pop() ?? "",
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Sim-attribution sidecar for a proposal.
460
+ *
461
+ * Mirrors `createCommentHistory` exactly — same `org.simocracy.history`
462
+ * lexicon, same join key shape, just `type: "proposal"` and
463
+ * `subjectCollection: "org.hypercerts.claim.activity"`. The lexicon's
464
+ * `type` field is free-form string; the webapp doesn't filter
465
+ * histories by `type === "proposal"` today, but adding a new value is
466
+ * fine (history.json already documents that new event types are
467
+ * appended over time).
468
+ *
469
+ * Writes to the *user's* own PDS, not a shared facilitator repo —
470
+ * the attribution is an event the user triggered and naturally
471
+ * belongs in their history.
472
+ */
473
+ export async function createProposalHistory(opts: {
474
+ agent: Agent;
475
+ did: string;
476
+ proposalUri: string;
477
+ proposalTitle: string;
478
+ simUri: string;
479
+ simName: string;
480
+ /** Plain-text description, denormalized for the timeline (truncated to ~5000 chars). */
481
+ content?: string;
482
+ }): Promise<{ uri: string; cid: string; rkey: string }> {
483
+ // Defense-in-depth: the sim must live in the same repo we're writing to.
484
+ // The proposal record itself isn't sim-owned, but the history sidecar
485
+ // *claims attribution to* a sim — only the sim's owner can make that claim.
486
+ assertRepoOwnsSimUri(opts.did, opts.simUri);
487
+ const title = opts.proposalTitle.trim();
488
+ const record: Record<string, unknown> = {
489
+ $type: COLLECTION_HISTORY,
490
+ type: "proposal",
491
+ actorDid: opts.did,
492
+ simNames: [opts.simName].slice(0, 10),
493
+ simUris: [opts.simUri].slice(0, 10),
494
+ subjectUri: opts.proposalUri,
495
+ subjectCollection: COLLECTION_PROPOSAL,
496
+ subjectName: title.slice(0, 500),
497
+ proposalTitle: title.slice(0, 500),
498
+ createdAt: new Date().toISOString(),
499
+ };
500
+ if (opts.content) {
501
+ const trimmed = opts.content.trim();
502
+ if (trimmed) record.content = trimmed.slice(0, 5000);
503
+ }
504
+ const res = await opts.agent.com.atproto.repo.createRecord({
505
+ repo: opts.did,
506
+ collection: COLLECTION_HISTORY,
507
+ record,
508
+ });
509
+ return {
510
+ uri: res.data.uri,
511
+ cid: res.data.cid,
512
+ rkey: res.data.uri.split("/").pop() ?? "",
513
+ };
514
+ }
515
+
279
516
  /**
280
517
  * Best-effort lookup of an existing rkey by listing the collection
281
518
  * and finding the record whose `sim.uri` matches. Used by the Apply