pi-simocracy 0.6.0 โ†’ 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/README.md CHANGED
@@ -39,6 +39,7 @@ pi install npm:pi-simocracy
39
39
  | `simocracy_chat` | Send one message to a sim and get a quoted reply, **without** changing the active session persona. Needs `OPENROUTER_API_KEY`. |
40
40
  | `simocracy_lookup_record` | Fetch a sim / proposal / gathering / decision / comment by AT-URI or fuzzy name. Returns the record + comment subtree, with sim-authored comments flagged inline (๐Ÿพ) so you can tell which opinions are human and which are sim. Use this before `simocracy_post_comment` to find the right `subjectUri`. |
41
41
  | `simocracy_post_comment` | Post a comment on a record **as the loaded sim**. Writes the comment plus an `org.simocracy.history` sidecar that attributes it to the sim. Requires `/sim login` + sim ownership. See [`docs/SIM_AUTHORED_COMMENTS.md`](docs/SIM_AUTHORED_COMMENTS.md) for the design. |
42
+ | `simocracy_post_proposal` | Submit a new funding proposal (`org.hypercerts.claim.activity`) **as the loaded sim**. Writes the proposal plus an `org.simocracy.history` sidecar with `type: "proposal"`. Optional itemized `budgetItems`, `workScope` tags, `contributors`, and an https `imageUri` (the default Simocracy banner is used otherwise โ€” image upload from disk is intentionally not supported). Requires `/sim login` + sim ownership. See [`docs/SIM_AUTHORED_PROPOSALS.md`](docs/SIM_AUTHORED_PROPOSALS.md) for the design. |
42
43
  | `simocracy_update_sim` | Rewrite the loaded sim's constitution (`shortDescription` + `description`) and/or speaking `style` and persist to your PDS. Requires `/sim login` + sim ownership. |
43
44
 
44
45
  ---
@@ -82,6 +83,7 @@ In Kitty / Ghostty / WezTerm / Konsole / iTerm2 the sprite renders as a true-col
82
83
 
83
84
  - [`AGENTS.md`](AGENTS.md) โ€” architecture, lexicons, write-path internals (read this before changing code).
84
85
  - [`docs/SIM_AUTHORED_COMMENTS.md`](docs/SIM_AUTHORED_COMMENTS.md) โ€” how human-vs-sim comment attribution works without changing the impactindexer lexicon.
86
+ - [`docs/SIM_AUTHORED_PROPOSALS.md`](docs/SIM_AUTHORED_PROPOSALS.md) โ€” same pattern, applied to `org.hypercerts.claim.activity` proposals.
85
87
  - [Simocracy](https://simocracy.org) ยท [pi](https://github.com/mariozechner/pi-coding-agent)
86
88
 
87
89
  MIT โ€” see [LICENSE](LICENSE).
@@ -0,0 +1,198 @@
1
+ # Sim-authored proposals
2
+
3
+ How pi-simocracy attributes a funding proposal to a sim *without*
4
+ extending the `org.hypercerts.claim.activity` lexicon โ€” and what
5
+ [simocracy-v2](https://github.com/GainForest/simocracy-v2) needs to
6
+ do to render the attribution. Same pattern as
7
+ [sim-authored comments](./SIM_AUTHORED_COMMENTS.md), different
8
+ subject collection.
9
+
10
+ ---
11
+
12
+ ## TL;DR
13
+
14
+ When pi submits a proposal on behalf of a loaded sim, it writes
15
+ **two** records to the user's PDS:
16
+
17
+ 1. **`org.hypercerts.claim.activity`** โ€” the proposal itself, in the
18
+ exact shape simocracy.org's `ProposalFormDialog` already writes
19
+ today (`title`, `shortDescription`, optional `description` /
20
+ `workScope` / `contributors` / `image`, `createdAt`). Old readers
21
+ (Hyperindexer, the existing webapp) see this as a regular
22
+ user-authored proposal โ€” graceful degradation.
23
+ 2. **`org.simocracy.history`** โ€” sidecar record with `type:
24
+ "proposal"`, `subjectUri` pointing at the proposal we just wrote,
25
+ `simUris[]` / `simNames[]` declaring which sim spoke. New `type`
26
+ value, but the lexicon's `type` field is free-form string and the
27
+ indexer already accepts new event types as they appear.
28
+
29
+ Renderers that understand the join (simocracy.org, when the planned
30
+ change lands) will display a sim badge on the proposal card;
31
+ renderers that don't keep showing it as a regular user proposal.
32
+ **Zero hypercerts lexicon changes. Zero new Simocracy lexicons.**
33
+
34
+ ---
35
+
36
+ ## Why no lexicon change
37
+
38
+ The `org.hypercerts.*` namespace is owned by the GainForest
39
+ hyperindexer project, not Simocracy โ€” extending
40
+ `org.hypercerts.claim.activity` with a `sim` StrongRef field would
41
+ couple two independent release cycles together for one cross-app
42
+ feature, and the same argument made for comments
43
+ ([`SIM_AUTHORED_COMMENTS.md`](./SIM_AUTHORED_COMMENTS.md)) applies
44
+ verbatim here.
45
+
46
+ The `org.simocracy.history` lexicon already has every field we need:
47
+
48
+ | Field | Used for sim-authored proposals |
49
+ |--------------------|--------------------------------------------------------------|
50
+ | `type` | `"proposal"` (new value โ€” appended like other event types) |
51
+ | `actorDid` | The human who submitted on the sim's behalf |
52
+ | `simNames[]` | Display name(s) of the sim(s) credited as author |
53
+ | `simUris[]` | AT-URI(s) of the sim(s) โ€” the sim-attribution key |
54
+ | `subjectUri` | AT-URI of the proposal record this attribution applies to |
55
+ | `subjectCollection`| `"org.hypercerts.claim.activity"` |
56
+ | `subjectName` | Proposal title (denormalized for the timeline) |
57
+ | `proposalTitle` | Same as `subjectName` โ€” kept parallel to comment sidecars |
58
+ | `content` | Denormalized description (so the indexer doesn't have to join across PDSs to display the timeline) |
59
+ | `createdAt` | ISO timestamp |
60
+
61
+ Use it as-is.
62
+
63
+ ---
64
+
65
+ ## Write path (pi-simocracy)
66
+
67
+ Implemented by `simocracy_post_proposal` in `src/index.ts`:
68
+
69
+ ```ts
70
+ // 1. The proposal โ€” same shape as ProposalFormDialog writes today.
71
+ const proposal = await createProposal({
72
+ agent, did,
73
+ title,
74
+ shortDescription,
75
+ description: finalDescription, // user body + appended budget block, if any
76
+ workScope,
77
+ contributors: contributors.map((c) => ({ contributorIdentity: c })),
78
+ image: { $type: "org.hypercerts.defs#uri", uri: imageUri ?? DEFAULT_BANNER },
79
+ });
80
+
81
+ // 2. The sim-attribution sidecar โ€” required, since attribution is the whole
82
+ // point of this tool.
83
+ await createProposalHistory({
84
+ agent, did,
85
+ proposalUri: proposal.uri,
86
+ proposalTitle: title,
87
+ simUri: loadedSim.uri,
88
+ simName: loadedSim.name,
89
+ content: finalDescription || shortDescription,
90
+ });
91
+ ```
92
+
93
+ Both writes go to the **user's** PDS via their OAuth session โ€” same
94
+ auth path that already powers `simocracy_post_comment` and
95
+ `simocracy_update_sim`. The write is gated on `/sim login` plus sim
96
+ ownership (the sim must live in the signed-in DID's repo) โ€” the
97
+ sidecar uses `assertRepoOwnsSimUri` as defense-in-depth, identical
98
+ to the comment path.
99
+
100
+ If the sidecar write fails after the proposal succeeds, **the
101
+ proposal is not rolled back** โ€” it just shows up unattributed until
102
+ the user retries. We don't roll back, because rolling back leaves an
103
+ orphaned tombstone in the user's repo that's harder to reason about
104
+ than a missing badge. The tool surfaces a `sidecarWarning` in the
105
+ result so the LLM can decide whether to retry.
106
+
107
+ ### What we deliberately don't do
108
+
109
+ - **Image upload from disk.** The webapp uploads to `/api/upload-blob`;
110
+ pi-simocracy has no equivalent and we deliberately stay
111
+ read-mostly on blobs. The tool accepts an https `imageUri` only,
112
+ with a default that mirrors the webapp's banner fallback.
113
+ - **Adding the proposal to a floor / collection.** That's a separate
114
+ `org.hypercerts.claim.collection` write the webapp does via
115
+ `/api/ftc-sf/add-to-collection`. Out of scope for this tool; a
116
+ follow-up tool can chain it after the fact.
117
+ - **Editing existing proposals.** Create-only for now.
118
+
119
+ ---
120
+
121
+ ## Read path (proposed simocracy-v2 changes)
122
+
123
+ Mirrors the comment renderer change in shape, applied to the
124
+ proposal list / detail views.
125
+
126
+ ### 1. Proposal list query โ€” pull history records in parallel
127
+
128
+ After fetching the proposals for a floor / collection, fetch all
129
+ `org.simocracy.history` records (capped, like notifications does)
130
+ and build a `Map<proposalUri, HistoryRecord>` keyed on `subjectUri`.
131
+ Filter to `type === "proposal"` and `subjectCollection ===
132
+ "org.hypercerts.claim.activity"`. Attach `simUri`, `simName`, and a
133
+ resolved `simAvatarUrl` to each proposal in the response that has a
134
+ match. Sim avatar resolution can reuse `fetchAllSimsWithMeta()` โ€”
135
+ no extra round-trips per proposal.
136
+
137
+ ### 2. Extend the proposal type
138
+
139
+ ```ts
140
+ export interface ProposalRecord {
141
+ // โ€ฆexisting fieldsโ€ฆ
142
+ simUri?: string
143
+ simName?: string
144
+ simAvatarUrl?: string
145
+ }
146
+ ```
147
+
148
+ No change to `ProposalFormDialog` โ€” that path stays for human
149
+ authors. Sim-authored proposals come from the CLI today, and a
150
+ future "submit as sim" button in the modal would bundle both writes
151
+ the same way pi-simocracy does.
152
+
153
+ ### 3. Proposal card โ€” sim badge
154
+
155
+ In the proposal card component, when `proposal.simUri` is set:
156
+
157
+ - Replace the author avatar with the sim's sprite (32ร—32 walk-1 frame).
158
+ - Render the byline as `๐Ÿพ {simName} ยท drafted by @{userHandle}` so
159
+ attribution stays unambiguous (the sim "drafted" it; the human
160
+ submitted and owns the record).
161
+ - Add a `[sim]` mono-uppercase badge alongside the existing meta
162
+ pills.
163
+ - Link the sim name to `/sims/{did}/{rkey}` via the existing slug
164
+ resolver.
165
+
166
+ Proposals without `simUri` keep rendering exactly as today โ€” no
167
+ regression for human-authored proposals.
168
+
169
+ ---
170
+
171
+ ## Querying sim-authored proposals
172
+
173
+ For "show me everything my sim has proposed":
174
+
175
+ ```ts
176
+ const histories = await fetchHistory() // existing helper
177
+ const mySimProposals = histories.filter(h =>
178
+ h.event.type === "proposal" &&
179
+ h.event.simUris?.includes(mySimUri)
180
+ )
181
+ ```
182
+
183
+ Each result has `subjectUri` (the proposal URI) and `content`
184
+ (denormalized description). Resolve the proposal URI for the full
185
+ record. Same query shape the notifications system already uses for
186
+ chat / hearing / sprocess events โ€” no new indexer queries needed.
187
+
188
+ ---
189
+
190
+ ## Status
191
+
192
+ - โœ… Implemented in pi-simocracy `simocracy_post_proposal` (this repo)
193
+ - ๐ŸŸก Renderer changes pending in simocracy-v2
194
+ - ๐ŸŸข Lexicons unchanged โ€” both repos can ship the change independently
195
+
196
+ The proposal + history pair is being written today. As soon as
197
+ simocracy-v2 lands the renderer change, every existing pi-authored
198
+ sim proposal retro-actively gets the sim badge โ€” no migration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
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
5
  "type": "module",
6
6
  "author": "David Dao <david@gainforest.earth> (https://github.com/daviddao)",
package/src/index.ts CHANGED
@@ -54,6 +54,13 @@
54
54
  * via an `org.simocracy.history` sidecar
55
55
  * (no impactindexer lexicon changes).
56
56
  * Requires /sim login + ownership.
57
+ * - `simocracy_post_proposal` Submit a new funding proposal
58
+ * (`org.hypercerts.claim.activity`) on
59
+ * behalf of the loaded sim, plus an
60
+ * `org.simocracy.history` sidecar with
61
+ * `type: "proposal"`. Same write pattern
62
+ * as `simocracy_post_comment`. Requires
63
+ * /sim login + sim ownership.
57
64
  * - `simocracy_lookup_record` Look up a sim / proposal / gathering /
58
65
  * decision / comment by AT-URI or fuzzy
59
66
  * name and return its details + comment
@@ -120,6 +127,8 @@ import {
120
127
  createAgents,
121
128
  createComment,
122
129
  createCommentHistory,
130
+ createProposal,
131
+ createProposalHistory,
123
132
  createStyle,
124
133
  findRkeyForSim,
125
134
  getAuthenticatedAgent,
@@ -478,6 +487,26 @@ async function renderSprite(sim: SimMatch): Promise<SpriteRender | null> {
478
487
  return null;
479
488
  }
480
489
 
490
+ /**
491
+ * Subcommand keywords reserved by the `/sim` dispatcher. The dispatcher
492
+ * routes these BEFORE falling through to `runLoadFlow`, but we also
493
+ * guard the load flow itself against them as defense-in-depth โ€” if a
494
+ * future regression ever leaks one of these into `runLoadFlow`, the
495
+ * user gets a "did you meanโ€ฆ?" hint instead of a misleading
496
+ * "Searching for 'login'โ€ฆ" + indexer-fetch error.
497
+ */
498
+ const RESERVED_SUBCOMMANDS = new Set([
499
+ "help",
500
+ "login",
501
+ "logout",
502
+ "whoami",
503
+ "my",
504
+ "mine",
505
+ "unload",
506
+ "clear",
507
+ "status",
508
+ ]);
509
+
481
510
  async function loadSimByName(query: string): Promise<{
482
511
  matches: SimMatch[];
483
512
  loaded?: LoadedSim;
@@ -487,7 +516,15 @@ async function loadSimByName(query: string): Promise<{
487
516
  try {
488
517
  matches = await searchSimsByName(query, { maxResults: 8 });
489
518
  } catch (err) {
490
- return { matches: [], error: `Indexer search failed: ${(err as Error).message}` };
519
+ const msg = (err as Error).message;
520
+ // Node's "fetch failed" is opaque โ€” the user can't tell whether the
521
+ // indexer is down, their network is down, or DNS is broken. Rewrite
522
+ // it into something actionable.
523
+ const friendly =
524
+ msg === "fetch failed" || msg.includes("fetch failed")
525
+ ? "could not reach the Simocracy indexer at simocracy-indexer-production.up.railway.app โ€” check your internet connection"
526
+ : msg;
527
+ return { matches: [], error: `Indexer search failed: ${friendly}` };
491
528
  }
492
529
  if (matches.length === 0) {
493
530
  return { matches: [], error: `No sim found matching "${query}".` };
@@ -638,6 +675,115 @@ const PostCommentToolParams = Type.Object({
638
675
  }),
639
676
  });
640
677
 
678
+ /**
679
+ * Default cover image used by simocracy.org's `ProposalFormDialog` when the
680
+ * user doesn't upload anything. We mirror that exactly so a pi-authored
681
+ * proposal renders with the same banner as a webapp-authored one.
682
+ */
683
+ const DEFAULT_PROPOSAL_BANNER_URI =
684
+ "https://www.simocracy.org/ftc-sf-default.jpeg";
685
+
686
+ /**
687
+ * Mirror of simocracy-v2's `appendBudgetToDescription` โ€” markers verbatim
688
+ * from `lib/budget-items.ts` so the block round-trips through the
689
+ * webapp's `parseDescriptionWithBudget` reader untouched. We only
690
+ * implement the *append* side here; pi-simocracy never parses
691
+ * existing descriptions back out (proposals are create-only).
692
+ *
693
+ * Returns the description unchanged when `items` is empty or contains
694
+ * no valid (non-empty name + positive amount) entries.
695
+ */
696
+ const BUDGET_HEADER = "โ”โ”โ” Budget Request โ”โ”โ”";
697
+ const TOTAL_PREFIX = "โ”โ”โ” Total: ";
698
+ const TOTAL_SUFFIX = " โ”โ”โ”";
699
+
700
+ function formatProposalUsd(amount: number): string {
701
+ const hasDecimals = !Number.isInteger(amount);
702
+ return new Intl.NumberFormat("en-US", {
703
+ style: "currency",
704
+ currency: "USD",
705
+ minimumFractionDigits: hasDecimals ? 2 : 0,
706
+ maximumFractionDigits: 2,
707
+ }).format(amount);
708
+ }
709
+
710
+ function appendBudgetToDescription(
711
+ description: string,
712
+ items: Array<{ item: string; amountUsd: number }>,
713
+ ): string {
714
+ const valid = items.filter(
715
+ (i) => i.item.trim() !== "" && i.amountUsd > 0 && Number.isFinite(i.amountUsd),
716
+ );
717
+ if (valid.length === 0) return description;
718
+ const total = valid.reduce((sum, i) => sum + i.amountUsd, 0);
719
+ const block = [
720
+ BUDGET_HEADER,
721
+ ...valid.map(
722
+ (i) => `โ€ข ${i.item.trim()} โ€” ${formatProposalUsd(i.amountUsd)}`,
723
+ ),
724
+ `${TOTAL_PREFIX}${formatProposalUsd(total)}${TOTAL_SUFFIX}`,
725
+ ].join("\n");
726
+ const base = description.trim();
727
+ return base ? `${base}\n\n${block}` : block;
728
+ }
729
+
730
+ const PostProposalToolParams = Type.Object({
731
+ title: Type.String({
732
+ description:
733
+ "Proposal title in the sim's voice. Required, max 256 chars. The sim is already in your system prompt โ€” write the title as they'd phrase it.",
734
+ minLength: 1,
735
+ maxLength: 256,
736
+ }),
737
+ shortDescription: Type.String({
738
+ description:
739
+ "One- or two-sentence pitch for the proposal, in the sim's voice. Required, max 300 chars. Shows up in proposal lists on simocracy.org.",
740
+ minLength: 1,
741
+ maxLength: 300,
742
+ }),
743
+ description: Type.Optional(
744
+ Type.String({
745
+ description:
746
+ "Long-form proposal body in the sim's voice. Plain text. Optional โ€” pass when the user has discussed the project in detail. If `budgetItems` is also passed, an itemized budget block is appended automatically.",
747
+ }),
748
+ ),
749
+ workScope: Type.Optional(
750
+ Type.String({
751
+ description:
752
+ "Comma-separated tags describing the work scope (e.g. \"urban agriculture, food security\"). Optional. Stored as a bare string the same way simocracy.org's webapp writes it.",
753
+ }),
754
+ ),
755
+ contributors: Type.Optional(
756
+ Type.Array(Type.String({ minLength: 1 }), {
757
+ description:
758
+ "DIDs, handles, or freeform names of people credited as contributors. One entry per contributor. Optional.",
759
+ }),
760
+ ),
761
+ budgetItems: Type.Optional(
762
+ Type.Array(
763
+ Type.Object({
764
+ item: Type.String({
765
+ minLength: 1,
766
+ description: "What the line-item is funding (e.g. \"Solar panels\").",
767
+ }),
768
+ amountUsd: Type.Number({
769
+ minimum: 0,
770
+ description: "USD amount for this line-item. Must be > 0 to be included.",
771
+ }),
772
+ }),
773
+ {
774
+ description:
775
+ "Itemized budget request. When provided, an `โ”โ”โ” Budget Request โ”โ”โ”` block is appended to `description` so it renders the same way simocracy.org's proposal form writes it. Pass when the user discussed a budget; omit when they didn't.",
776
+ },
777
+ ),
778
+ ),
779
+ imageUri: Type.Optional(
780
+ Type.String({
781
+ description:
782
+ "https URL for the cover image. Defaults to the Simocracy banner if omitted. Image upload from disk is not supported โ€” pass a URL or leave blank.",
783
+ }),
784
+ ),
785
+ });
786
+
641
787
  const LookupRecordToolParams = Type.Object({
642
788
  query: Type.String({
643
789
  description:
@@ -809,8 +955,18 @@ export default async function simocracy(pi: ExtensionAPI) {
809
955
  description:
810
956
  "Simocracy: load sims, edit your own sim's constitution/style, sign into ATProto. `/sim help` for the full list.",
811
957
  handler: async (args, ctx) => {
812
- const arg = args.trim();
813
- if (!arg || arg === "help" || arg === "--help") {
958
+ // Strip zero-width / format characters that survive `.trim()` โ€”
959
+ // a stray U+200B (ZWSP) glued onto "login" by a paste from a
960
+ // chat client is enough to make `arg === "login"` fail and
961
+ // route the request through `runLoadFlow` as if it were a sim
962
+ // name. We match subcommand keywords against the *lowercased*
963
+ // form, but pass the original-case clean arg through to handlers
964
+ // (sim names are user-facing strings; preserve their case).
965
+ const arg = args
966
+ .trim()
967
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "");
968
+ const argLower = arg.toLowerCase();
969
+ if (!arg || argLower === "help" || argLower === "--help") {
814
970
  ctx.ui.notify(
815
971
  "Sim:\n" +
816
972
  " /sim <name> load a sim (e.g. /sim mr meow)\n" +
@@ -837,27 +993,28 @@ export default async function simocracy(pi: ExtensionAPI) {
837
993
  }
838
994
  // ATProto auth subcommands โ€” must come BEFORE the sim-name
839
995
  // fallthrough (`runLoadFlow`) so we don't accidentally treat
840
- // "login" as a sim name to load from the indexer.
841
- if (arg === "login" || arg.startsWith("login ") || arg.startsWith("login\t")) {
996
+ // "login" as a sim name to load from the indexer. Match on
997
+ // `argLower` so `/sim Login` and `/sim LOGIN` route the same way.
998
+ if (argLower === "login" || argLower.startsWith("login ") || argLower.startsWith("login\t")) {
842
999
  const rest = arg.slice("login".length).trim();
843
1000
  await runLogin(ctx, rest);
844
1001
  return;
845
1002
  }
846
- if (arg === "logout") {
1003
+ if (argLower === "logout") {
847
1004
  await runLogout(ctx);
848
1005
  return;
849
1006
  }
850
- if (arg === "whoami") {
1007
+ if (argLower === "whoami") {
851
1008
  await runWhoami(ctx);
852
1009
  return;
853
1010
  }
854
- if (arg === "my" || arg === "mine" || arg.startsWith("my ") || arg.startsWith("my\t") || arg.startsWith("mine ") || arg.startsWith("mine\t")) {
855
- const headLen = arg.startsWith("mine") ? 4 : 2;
1011
+ if (argLower === "my" || argLower === "mine" || argLower.startsWith("my ") || argLower.startsWith("my\t") || argLower.startsWith("mine ") || argLower.startsWith("mine\t")) {
1012
+ const headLen = argLower.startsWith("mine") ? 4 : 2;
856
1013
  const rest = arg.slice(headLen).trim();
857
1014
  await runMySimsCommand(pi, ctx, rest);
858
1015
  return;
859
1016
  }
860
- if (arg === "unload" || arg === "clear") {
1017
+ if (argLower === "unload" || argLower === "clear") {
861
1018
  if (!loadedSim) {
862
1019
  ctx.ui.notify("No sim loaded.", "info");
863
1020
  return;
@@ -871,7 +1028,7 @@ export default async function simocracy(pi: ExtensionAPI) {
871
1028
  ctx.ui.notify(`Unloaded ${name}. Pi will break character on the next reply.`, "info");
872
1029
  return;
873
1030
  }
874
- if (arg === "status") {
1031
+ if (argLower === "status") {
875
1032
  if (!loadedSim) {
876
1033
  ctx.ui.notify("No sim loaded. Try `/sim mr meow`.", "info");
877
1034
  return;
@@ -1310,6 +1467,162 @@ export default async function simocracy(pi: ExtensionAPI) {
1310
1467
  },
1311
1468
  });
1312
1469
 
1470
+ // -------------------------------------------------------------------------
1471
+ // Tool: simocracy_post_proposal
1472
+ //
1473
+ // Submit a new funding proposal on behalf of the loaded sim. Two
1474
+ // records are written to the user's PDS, mirroring simocracy_post_comment:
1475
+ //
1476
+ // 1. org.hypercerts.claim.activity the proposal itself, in the same
1477
+ // wire shape simocracy.org's ProposalFormDialog writes today, so it
1478
+ // renders identically in the webapp.
1479
+ // 2. org.simocracy.history sidecar with type="proposal",
1480
+ // simUris=[loadedSim], subjectUri=<proposal uri>. Renderers that
1481
+ // understand the join show the sim badge; others see a regular
1482
+ // proposal โ€” graceful degradation, zero lexicon changes.
1483
+ //
1484
+ // See `docs/SIM_AUTHORED_PROPOSALS.md` for the full design.
1485
+ // -------------------------------------------------------------------------
1486
+ pi.registerTool({
1487
+ name: "simocracy_post_proposal",
1488
+ label: "Submit a Simocracy proposal as the loaded sim",
1489
+ description:
1490
+ "Submit a new funding proposal to Simocracy on behalf of the currently loaded sim. The sim should write the title + shortDescription + description in their own voice (their persona is already in your system prompt). Writes the proposal to the user's PDS plus an org.simocracy.history sidecar attributing the draft to the loaded sim. Use this when the user asks the sim to draft, propose, or submit a proposal โ€” e.g. \"Mr Meow, propose a cat sanctuary\" or \"draft a proposal for solar panels\". Pass `budgetItems` if a budget request was discussed; pass `workScope` for tag-style categorization; pass `contributors` for credited humans. Image is optional and URL-only (the default Simocracy banner is used otherwise). Requires /sim login + a loaded sim the user owns.",
1491
+ parameters: PostProposalToolParams,
1492
+ async execute(
1493
+ _id,
1494
+ { title, shortDescription, description, workScope, contributors, budgetItems, imageUri },
1495
+ ) {
1496
+ if (!loadedSim) {
1497
+ throw new Error(
1498
+ "No sim loaded. Call simocracy_load_sim first โ€” proposals are submitted on behalf of a specific sim.",
1499
+ );
1500
+ }
1501
+ let auth;
1502
+ try {
1503
+ auth = await assertCanWriteToSim(loadedSim, { action: "post a proposal as" });
1504
+ } catch (err) {
1505
+ if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
1506
+ throw new Error(err.message);
1507
+ }
1508
+ throw err;
1509
+ }
1510
+ let pdsAgent;
1511
+ try {
1512
+ ({ agent: pdsAgent } = await getAuthenticatedAgent());
1513
+ } catch (err) {
1514
+ if (err instanceof NotSignedInError) throw new Error(err.message);
1515
+ throw new Error(`ATProto auth failed: ${(err as Error).message}`);
1516
+ }
1517
+
1518
+ // Resolve the cover image โ€” either an LLM-supplied https URL or
1519
+ // the simocracy.org default banner. Reject non-https schemes
1520
+ // (data:, javascript:, file://) defensively even though the only
1521
+ // real downstream consumer is the webapp's <Image> component.
1522
+ let imageRef: { $type: "org.hypercerts.defs#uri"; uri: string };
1523
+ if (imageUri !== undefined) {
1524
+ const trimmed = imageUri.trim();
1525
+ if (!/^https:\/\//i.test(trimmed)) {
1526
+ throw new Error(
1527
+ `imageUri must be an https URL (got "${trimmed}"). Pass an https URL, or omit imageUri to use the default Simocracy banner.`,
1528
+ );
1529
+ }
1530
+ imageRef = { $type: "org.hypercerts.defs#uri", uri: trimmed };
1531
+ } else {
1532
+ imageRef = { $type: "org.hypercerts.defs#uri", uri: DEFAULT_PROPOSAL_BANNER_URI };
1533
+ }
1534
+
1535
+ // Append the budget block (if any) to the user-authored description,
1536
+ // exactly the same way simocracy.org's ProposalFormDialog does.
1537
+ const baseDescription = description?.trim() ?? "";
1538
+ const finalDescription = budgetItems
1539
+ ? appendBudgetToDescription(baseDescription, budgetItems)
1540
+ : baseDescription;
1541
+
1542
+ // Build contributors in the lexicon shape:
1543
+ // `Array<{ contributorIdentity: string }>`. Drop blank entries.
1544
+ const contributorRecords =
1545
+ contributors && contributors.length > 0
1546
+ ? contributors
1547
+ .map((c) => c.trim())
1548
+ .filter((c) => c.length > 0)
1549
+ .map((contributorIdentity) => ({ contributorIdentity }))
1550
+ : undefined;
1551
+
1552
+ let proposal;
1553
+ try {
1554
+ proposal = await createProposal({
1555
+ agent: pdsAgent,
1556
+ did: auth.did,
1557
+ title,
1558
+ shortDescription,
1559
+ description: finalDescription || undefined,
1560
+ workScope: workScope?.trim() || undefined,
1561
+ contributors:
1562
+ contributorRecords && contributorRecords.length > 0
1563
+ ? contributorRecords
1564
+ : undefined,
1565
+ image: imageRef,
1566
+ });
1567
+ } catch (err) {
1568
+ throw new Error(`Proposal write failed: ${(err as Error).message}`);
1569
+ }
1570
+
1571
+ let sidecarUri: string | undefined;
1572
+ let sidecarWarning: string | undefined;
1573
+ try {
1574
+ const history = await createProposalHistory({
1575
+ agent: pdsAgent,
1576
+ did: auth.did,
1577
+ proposalUri: proposal.uri,
1578
+ proposalTitle: title,
1579
+ simUri: loadedSim.uri,
1580
+ simName: loadedSim.name,
1581
+ content: finalDescription || shortDescription,
1582
+ });
1583
+ sidecarUri = history.uri;
1584
+ } catch (err) {
1585
+ // Don't roll back โ€” the proposal is already on the user's PDS.
1586
+ // The sidecar can be re-written later. Surface the warning so the
1587
+ // LLM can decide whether to retry.
1588
+ sidecarWarning = `Sim-attribution sidecar failed: ${(err as Error).message}`;
1589
+ }
1590
+
1591
+ const lines = [
1592
+ `Submitted proposal as ${loadedSim.name}${loadedSim.handle ? ` (@${loadedSim.handle})` : ""}:`,
1593
+ ` title: ${title}`,
1594
+ ` proposal URI: ${proposal.uri}`,
1595
+ ` image: ${imageRef.uri}`,
1596
+ ];
1597
+ if (sidecarUri) {
1598
+ lines.push(` attribution: ${sidecarUri} (org.simocracy.history sidecar)`);
1599
+ } else if (sidecarWarning) {
1600
+ lines.push(` WARNING: ${sidecarWarning}`);
1601
+ lines.push(
1602
+ ` The proposal is posted but will appear unattributed until a history sidecar is written.`,
1603
+ );
1604
+ }
1605
+ return {
1606
+ content: [{ type: "text" as const, text: lines.join("\n") }],
1607
+ details: {
1608
+ proposalUri: proposal.uri,
1609
+ proposalRkey: proposal.rkey,
1610
+ proposalCid: proposal.cid,
1611
+ title,
1612
+ shortDescription,
1613
+ imageUri: imageRef.uri,
1614
+ workScope: workScope?.trim() || undefined,
1615
+ contributors: contributorRecords,
1616
+ budgetItemCount: budgetItems?.length ?? 0,
1617
+ simUri: loadedSim.uri,
1618
+ simName: loadedSim.name,
1619
+ sidecarUri,
1620
+ sidecarWarning,
1621
+ },
1622
+ };
1623
+ },
1624
+ });
1625
+
1313
1626
  // -------------------------------------------------------------------------
1314
1627
  // Tool: simocracy_lookup_record
1315
1628
  //
@@ -1822,6 +2135,19 @@ async function runLoadFlow(
1822
2135
  ctx: ExtensionCommandContext,
1823
2136
  arg: string,
1824
2137
  ): Promise<void> {
2138
+ // Defense-in-depth: if a reserved subcommand keyword somehow ends up
2139
+ // here (e.g. dispatcher regression, exotic input that bypassed the
2140
+ // case + zero-width normalization in the `/sim` handler), refuse to
2141
+ // search the indexer for it. Otherwise the user sees a misleading
2142
+ // `Searching for "login"โ€ฆ` followed by an indexer-fetch error.
2143
+ const argTrimmed = arg.trim();
2144
+ if (RESERVED_SUBCOMMANDS.has(argTrimmed.toLowerCase())) {
2145
+ ctx.ui.notify(
2146
+ `\`${argTrimmed}\` is a reserved subcommand. Did you mean \`/sim ${argTrimmed.toLowerCase()}\`? Run \`/sim help\` for the full list.`,
2147
+ "error",
2148
+ );
2149
+ return;
2150
+ }
1825
2151
  ctx.ui.notify(`Searching for "${arg}"โ€ฆ`, "info");
1826
2152
  let matches: SimMatch[] = [];
1827
2153
  if (arg.startsWith("at://")) {
package/src/writes.ts CHANGED
@@ -129,6 +129,7 @@ const COLLECTION_AGENTS = "org.simocracy.agents";
129
129
  const COLLECTION_STYLE = "org.simocracy.style";
130
130
  const COLLECTION_COMMENT = "org.impactindexer.review.comment";
131
131
  const COLLECTION_HISTORY = "org.simocracy.history";
132
+ const COLLECTION_PROPOSAL = "org.hypercerts.claim.activity";
132
133
 
133
134
  /**
134
135
  * Defense-in-depth: every write helper below verifies the target
@@ -390,6 +391,128 @@ export async function createCommentHistory(opts: {
390
391
  };
391
392
  }
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
+
393
516
  /**
394
517
  * Best-effort lookup of an existing rkey by listing the collection
395
518
  * and finding the record whose `sim.uri` matches. Used by the Apply