pi-simocracy 0.6.0 โ 0.6.2
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 +13 -0
- package/docs/SIM_AUTHORED_PROPOSALS.md +198 -0
- package/package.json +1 -1
- package/src/index.ts +344 -13
- package/src/persona.ts +26 -0
- package/src/simocracy.ts +45 -0
- package/src/writes.ts +123 -0
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
|
---
|
|
@@ -68,6 +69,17 @@ Pi calls `simocracy_lookup_record` to find the AT-URI, then `simocracy_post_comm
|
|
|
68
69
|
|
|
69
70
|
---
|
|
70
71
|
|
|
72
|
+
## Loaded-sim system prompt
|
|
73
|
+
|
|
74
|
+
When a sim is loaded, pi injects the sim's identity, constitution, and speaking style into the system prompt every turn. On top of that, the persona prompt appends a **Simocracy navigation cheat-sheet** fetched live from [`simocracy.org/skill.md`](https://www.simocracy.org/skill.md) at sim-load time โ that's where the URL patterns (`/sims/<did>/<rkey>`, `/profile/<handle>`, โฆ), indexer endpoints, and recommended tool-routing live. simocracy.org is the single source of truth; this extension keeps no baked-in fallback. If the fetch fails (offline, simocracy.org down, route not yet deployed) the section is simply omitted โ the sim still loads, it just lacks the navigation guidance until the URL becomes reachable.
|
|
75
|
+
|
|
76
|
+
Override or disable:
|
|
77
|
+
|
|
78
|
+
- `SIMOCRACY_SKILL_URL=โฆ` โ point at a staging URL (the route is also viewable in a browser).
|
|
79
|
+
- `SIMOCRACY_SKILL_MD_DISABLED=1` โ skip the fetch entirely (useful on metered connections / offline).
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
71
83
|
## Sprite rendering
|
|
72
84
|
|
|
73
85
|
Two formats supported:
|
|
@@ -82,6 +94,7 @@ In Kitty / Ghostty / WezTerm / Konsole / iTerm2 the sprite renders as a true-col
|
|
|
82
94
|
|
|
83
95
|
- [`AGENTS.md`](AGENTS.md) โ architecture, lexicons, write-path internals (read this before changing code).
|
|
84
96
|
- [`docs/SIM_AUTHORED_COMMENTS.md`](docs/SIM_AUTHORED_COMMENTS.md) โ how human-vs-sim comment attribution works without changing the impactindexer lexicon.
|
|
97
|
+
- [`docs/SIM_AUTHORED_PROPOSALS.md`](docs/SIM_AUTHORED_PROPOSALS.md) โ same pattern, applied to `org.hypercerts.claim.activity` proposals.
|
|
85
98
|
- [Simocracy](https://simocracy.org) ยท [pi](https://github.com/mariozechner/pi-coding-agent)
|
|
86
99
|
|
|
87
100
|
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.
|
|
3
|
+
"version": "0.6.2",
|
|
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
|
|
@@ -88,6 +95,7 @@ import {
|
|
|
88
95
|
fetchAgentsForSim,
|
|
89
96
|
fetchStyleForSim,
|
|
90
97
|
fetchBlob,
|
|
98
|
+
fetchSkillMd,
|
|
91
99
|
resolveHandle,
|
|
92
100
|
parseAtUri,
|
|
93
101
|
type AgentsRecord,
|
|
@@ -120,6 +128,8 @@ import {
|
|
|
120
128
|
createAgents,
|
|
121
129
|
createComment,
|
|
122
130
|
createCommentHistory,
|
|
131
|
+
createProposal,
|
|
132
|
+
createProposalHistory,
|
|
123
133
|
createStyle,
|
|
124
134
|
findRkeyForSim,
|
|
125
135
|
getAuthenticatedAgent,
|
|
@@ -478,6 +488,26 @@ async function renderSprite(sim: SimMatch): Promise<SpriteRender | null> {
|
|
|
478
488
|
return null;
|
|
479
489
|
}
|
|
480
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Subcommand keywords reserved by the `/sim` dispatcher. The dispatcher
|
|
493
|
+
* routes these BEFORE falling through to `runLoadFlow`, but we also
|
|
494
|
+
* guard the load flow itself against them as defense-in-depth โ if a
|
|
495
|
+
* future regression ever leaks one of these into `runLoadFlow`, the
|
|
496
|
+
* user gets a "did you meanโฆ?" hint instead of a misleading
|
|
497
|
+
* "Searching for 'login'โฆ" + indexer-fetch error.
|
|
498
|
+
*/
|
|
499
|
+
const RESERVED_SUBCOMMANDS = new Set([
|
|
500
|
+
"help",
|
|
501
|
+
"login",
|
|
502
|
+
"logout",
|
|
503
|
+
"whoami",
|
|
504
|
+
"my",
|
|
505
|
+
"mine",
|
|
506
|
+
"unload",
|
|
507
|
+
"clear",
|
|
508
|
+
"status",
|
|
509
|
+
]);
|
|
510
|
+
|
|
481
511
|
async function loadSimByName(query: string): Promise<{
|
|
482
512
|
matches: SimMatch[];
|
|
483
513
|
loaded?: LoadedSim;
|
|
@@ -487,7 +517,15 @@ async function loadSimByName(query: string): Promise<{
|
|
|
487
517
|
try {
|
|
488
518
|
matches = await searchSimsByName(query, { maxResults: 8 });
|
|
489
519
|
} catch (err) {
|
|
490
|
-
|
|
520
|
+
const msg = (err as Error).message;
|
|
521
|
+
// Node's "fetch failed" is opaque โ the user can't tell whether the
|
|
522
|
+
// indexer is down, their network is down, or DNS is broken. Rewrite
|
|
523
|
+
// it into something actionable.
|
|
524
|
+
const friendly =
|
|
525
|
+
msg === "fetch failed" || msg.includes("fetch failed")
|
|
526
|
+
? "could not reach the Simocracy indexer at simocracy-indexer-production.up.railway.app โ check your internet connection"
|
|
527
|
+
: msg;
|
|
528
|
+
return { matches: [], error: `Indexer search failed: ${friendly}` };
|
|
491
529
|
}
|
|
492
530
|
if (matches.length === 0) {
|
|
493
531
|
return { matches: [], error: `No sim found matching "${query}".` };
|
|
@@ -496,12 +534,14 @@ async function loadSimByName(query: string): Promise<{
|
|
|
496
534
|
}
|
|
497
535
|
|
|
498
536
|
async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
|
|
499
|
-
// Fetch agents (constitution), style, sprite ANSI
|
|
500
|
-
|
|
537
|
+
// Fetch agents (constitution), style, sprite ANSI, handle, and the
|
|
538
|
+
// simocracy.org navigation cheat-sheet in parallel.
|
|
539
|
+
const [agents, style, sprite, handle, skill] = await Promise.all([
|
|
501
540
|
fetchAgentsForSim(match.uri).catch(() => null) as Promise<AgentsRecord | null>,
|
|
502
541
|
fetchStyleForSim(match.uri).catch(() => null) as Promise<StyleRecord | null>,
|
|
503
542
|
renderSprite(match).catch(() => null),
|
|
504
543
|
resolveHandle(match.did).catch(() => null),
|
|
544
|
+
fetchSkillMd(),
|
|
505
545
|
]);
|
|
506
546
|
|
|
507
547
|
return {
|
|
@@ -529,6 +569,8 @@ async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
|
|
|
529
569
|
shortDescription: agents?.shortDescription,
|
|
530
570
|
description: agents?.description,
|
|
531
571
|
style: style?.description,
|
|
572
|
+
skillMd: skill.text,
|
|
573
|
+
skillMdError: skill.text ? undefined : skill.error,
|
|
532
574
|
};
|
|
533
575
|
}
|
|
534
576
|
|
|
@@ -638,6 +680,115 @@ const PostCommentToolParams = Type.Object({
|
|
|
638
680
|
}),
|
|
639
681
|
});
|
|
640
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Default cover image used by simocracy.org's `ProposalFormDialog` when the
|
|
685
|
+
* user doesn't upload anything. We mirror that exactly so a pi-authored
|
|
686
|
+
* proposal renders with the same banner as a webapp-authored one.
|
|
687
|
+
*/
|
|
688
|
+
const DEFAULT_PROPOSAL_BANNER_URI =
|
|
689
|
+
"https://www.simocracy.org/ftc-sf-default.jpeg";
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Mirror of simocracy-v2's `appendBudgetToDescription` โ markers verbatim
|
|
693
|
+
* from `lib/budget-items.ts` so the block round-trips through the
|
|
694
|
+
* webapp's `parseDescriptionWithBudget` reader untouched. We only
|
|
695
|
+
* implement the *append* side here; pi-simocracy never parses
|
|
696
|
+
* existing descriptions back out (proposals are create-only).
|
|
697
|
+
*
|
|
698
|
+
* Returns the description unchanged when `items` is empty or contains
|
|
699
|
+
* no valid (non-empty name + positive amount) entries.
|
|
700
|
+
*/
|
|
701
|
+
const BUDGET_HEADER = "โโโ Budget Request โโโ";
|
|
702
|
+
const TOTAL_PREFIX = "โโโ Total: ";
|
|
703
|
+
const TOTAL_SUFFIX = " โโโ";
|
|
704
|
+
|
|
705
|
+
function formatProposalUsd(amount: number): string {
|
|
706
|
+
const hasDecimals = !Number.isInteger(amount);
|
|
707
|
+
return new Intl.NumberFormat("en-US", {
|
|
708
|
+
style: "currency",
|
|
709
|
+
currency: "USD",
|
|
710
|
+
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
711
|
+
maximumFractionDigits: 2,
|
|
712
|
+
}).format(amount);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function appendBudgetToDescription(
|
|
716
|
+
description: string,
|
|
717
|
+
items: Array<{ item: string; amountUsd: number }>,
|
|
718
|
+
): string {
|
|
719
|
+
const valid = items.filter(
|
|
720
|
+
(i) => i.item.trim() !== "" && i.amountUsd > 0 && Number.isFinite(i.amountUsd),
|
|
721
|
+
);
|
|
722
|
+
if (valid.length === 0) return description;
|
|
723
|
+
const total = valid.reduce((sum, i) => sum + i.amountUsd, 0);
|
|
724
|
+
const block = [
|
|
725
|
+
BUDGET_HEADER,
|
|
726
|
+
...valid.map(
|
|
727
|
+
(i) => `โข ${i.item.trim()} โ ${formatProposalUsd(i.amountUsd)}`,
|
|
728
|
+
),
|
|
729
|
+
`${TOTAL_PREFIX}${formatProposalUsd(total)}${TOTAL_SUFFIX}`,
|
|
730
|
+
].join("\n");
|
|
731
|
+
const base = description.trim();
|
|
732
|
+
return base ? `${base}\n\n${block}` : block;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const PostProposalToolParams = Type.Object({
|
|
736
|
+
title: Type.String({
|
|
737
|
+
description:
|
|
738
|
+
"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.",
|
|
739
|
+
minLength: 1,
|
|
740
|
+
maxLength: 256,
|
|
741
|
+
}),
|
|
742
|
+
shortDescription: Type.String({
|
|
743
|
+
description:
|
|
744
|
+
"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.",
|
|
745
|
+
minLength: 1,
|
|
746
|
+
maxLength: 300,
|
|
747
|
+
}),
|
|
748
|
+
description: Type.Optional(
|
|
749
|
+
Type.String({
|
|
750
|
+
description:
|
|
751
|
+
"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.",
|
|
752
|
+
}),
|
|
753
|
+
),
|
|
754
|
+
workScope: Type.Optional(
|
|
755
|
+
Type.String({
|
|
756
|
+
description:
|
|
757
|
+
"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.",
|
|
758
|
+
}),
|
|
759
|
+
),
|
|
760
|
+
contributors: Type.Optional(
|
|
761
|
+
Type.Array(Type.String({ minLength: 1 }), {
|
|
762
|
+
description:
|
|
763
|
+
"DIDs, handles, or freeform names of people credited as contributors. One entry per contributor. Optional.",
|
|
764
|
+
}),
|
|
765
|
+
),
|
|
766
|
+
budgetItems: Type.Optional(
|
|
767
|
+
Type.Array(
|
|
768
|
+
Type.Object({
|
|
769
|
+
item: Type.String({
|
|
770
|
+
minLength: 1,
|
|
771
|
+
description: "What the line-item is funding (e.g. \"Solar panels\").",
|
|
772
|
+
}),
|
|
773
|
+
amountUsd: Type.Number({
|
|
774
|
+
minimum: 0,
|
|
775
|
+
description: "USD amount for this line-item. Must be > 0 to be included.",
|
|
776
|
+
}),
|
|
777
|
+
}),
|
|
778
|
+
{
|
|
779
|
+
description:
|
|
780
|
+
"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.",
|
|
781
|
+
},
|
|
782
|
+
),
|
|
783
|
+
),
|
|
784
|
+
imageUri: Type.Optional(
|
|
785
|
+
Type.String({
|
|
786
|
+
description:
|
|
787
|
+
"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.",
|
|
788
|
+
}),
|
|
789
|
+
),
|
|
790
|
+
});
|
|
791
|
+
|
|
641
792
|
const LookupRecordToolParams = Type.Object({
|
|
642
793
|
query: Type.String({
|
|
643
794
|
description:
|
|
@@ -809,8 +960,18 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
809
960
|
description:
|
|
810
961
|
"Simocracy: load sims, edit your own sim's constitution/style, sign into ATProto. `/sim help` for the full list.",
|
|
811
962
|
handler: async (args, ctx) => {
|
|
812
|
-
|
|
813
|
-
|
|
963
|
+
// Strip zero-width / format characters that survive `.trim()` โ
|
|
964
|
+
// a stray U+200B (ZWSP) glued onto "login" by a paste from a
|
|
965
|
+
// chat client is enough to make `arg === "login"` fail and
|
|
966
|
+
// route the request through `runLoadFlow` as if it were a sim
|
|
967
|
+
// name. We match subcommand keywords against the *lowercased*
|
|
968
|
+
// form, but pass the original-case clean arg through to handlers
|
|
969
|
+
// (sim names are user-facing strings; preserve their case).
|
|
970
|
+
const arg = args
|
|
971
|
+
.trim()
|
|
972
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "");
|
|
973
|
+
const argLower = arg.toLowerCase();
|
|
974
|
+
if (!arg || argLower === "help" || argLower === "--help") {
|
|
814
975
|
ctx.ui.notify(
|
|
815
976
|
"Sim:\n" +
|
|
816
977
|
" /sim <name> load a sim (e.g. /sim mr meow)\n" +
|
|
@@ -837,27 +998,28 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
837
998
|
}
|
|
838
999
|
// ATProto auth subcommands โ must come BEFORE the sim-name
|
|
839
1000
|
// fallthrough (`runLoadFlow`) so we don't accidentally treat
|
|
840
|
-
// "login" as a sim name to load from the indexer.
|
|
841
|
-
|
|
1001
|
+
// "login" as a sim name to load from the indexer. Match on
|
|
1002
|
+
// `argLower` so `/sim Login` and `/sim LOGIN` route the same way.
|
|
1003
|
+
if (argLower === "login" || argLower.startsWith("login ") || argLower.startsWith("login\t")) {
|
|
842
1004
|
const rest = arg.slice("login".length).trim();
|
|
843
1005
|
await runLogin(ctx, rest);
|
|
844
1006
|
return;
|
|
845
1007
|
}
|
|
846
|
-
if (
|
|
1008
|
+
if (argLower === "logout") {
|
|
847
1009
|
await runLogout(ctx);
|
|
848
1010
|
return;
|
|
849
1011
|
}
|
|
850
|
-
if (
|
|
1012
|
+
if (argLower === "whoami") {
|
|
851
1013
|
await runWhoami(ctx);
|
|
852
1014
|
return;
|
|
853
1015
|
}
|
|
854
|
-
if (
|
|
855
|
-
const headLen =
|
|
1016
|
+
if (argLower === "my" || argLower === "mine" || argLower.startsWith("my ") || argLower.startsWith("my\t") || argLower.startsWith("mine ") || argLower.startsWith("mine\t")) {
|
|
1017
|
+
const headLen = argLower.startsWith("mine") ? 4 : 2;
|
|
856
1018
|
const rest = arg.slice(headLen).trim();
|
|
857
1019
|
await runMySimsCommand(pi, ctx, rest);
|
|
858
1020
|
return;
|
|
859
1021
|
}
|
|
860
|
-
if (
|
|
1022
|
+
if (argLower === "unload" || argLower === "clear") {
|
|
861
1023
|
if (!loadedSim) {
|
|
862
1024
|
ctx.ui.notify("No sim loaded.", "info");
|
|
863
1025
|
return;
|
|
@@ -871,7 +1033,7 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
871
1033
|
ctx.ui.notify(`Unloaded ${name}. Pi will break character on the next reply.`, "info");
|
|
872
1034
|
return;
|
|
873
1035
|
}
|
|
874
|
-
if (
|
|
1036
|
+
if (argLower === "status") {
|
|
875
1037
|
if (!loadedSim) {
|
|
876
1038
|
ctx.ui.notify("No sim loaded. Try `/sim mr meow`.", "info");
|
|
877
1039
|
return;
|
|
@@ -1310,6 +1472,162 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
1310
1472
|
},
|
|
1311
1473
|
});
|
|
1312
1474
|
|
|
1475
|
+
// -------------------------------------------------------------------------
|
|
1476
|
+
// Tool: simocracy_post_proposal
|
|
1477
|
+
//
|
|
1478
|
+
// Submit a new funding proposal on behalf of the loaded sim. Two
|
|
1479
|
+
// records are written to the user's PDS, mirroring simocracy_post_comment:
|
|
1480
|
+
//
|
|
1481
|
+
// 1. org.hypercerts.claim.activity the proposal itself, in the same
|
|
1482
|
+
// wire shape simocracy.org's ProposalFormDialog writes today, so it
|
|
1483
|
+
// renders identically in the webapp.
|
|
1484
|
+
// 2. org.simocracy.history sidecar with type="proposal",
|
|
1485
|
+
// simUris=[loadedSim], subjectUri=<proposal uri>. Renderers that
|
|
1486
|
+
// understand the join show the sim badge; others see a regular
|
|
1487
|
+
// proposal โ graceful degradation, zero lexicon changes.
|
|
1488
|
+
//
|
|
1489
|
+
// See `docs/SIM_AUTHORED_PROPOSALS.md` for the full design.
|
|
1490
|
+
// -------------------------------------------------------------------------
|
|
1491
|
+
pi.registerTool({
|
|
1492
|
+
name: "simocracy_post_proposal",
|
|
1493
|
+
label: "Submit a Simocracy proposal as the loaded sim",
|
|
1494
|
+
description:
|
|
1495
|
+
"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.",
|
|
1496
|
+
parameters: PostProposalToolParams,
|
|
1497
|
+
async execute(
|
|
1498
|
+
_id,
|
|
1499
|
+
{ title, shortDescription, description, workScope, contributors, budgetItems, imageUri },
|
|
1500
|
+
) {
|
|
1501
|
+
if (!loadedSim) {
|
|
1502
|
+
throw new Error(
|
|
1503
|
+
"No sim loaded. Call simocracy_load_sim first โ proposals are submitted on behalf of a specific sim.",
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
let auth;
|
|
1507
|
+
try {
|
|
1508
|
+
auth = await assertCanWriteToSim(loadedSim, { action: "post a proposal as" });
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
|
|
1511
|
+
throw new Error(err.message);
|
|
1512
|
+
}
|
|
1513
|
+
throw err;
|
|
1514
|
+
}
|
|
1515
|
+
let pdsAgent;
|
|
1516
|
+
try {
|
|
1517
|
+
({ agent: pdsAgent } = await getAuthenticatedAgent());
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
if (err instanceof NotSignedInError) throw new Error(err.message);
|
|
1520
|
+
throw new Error(`ATProto auth failed: ${(err as Error).message}`);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Resolve the cover image โ either an LLM-supplied https URL or
|
|
1524
|
+
// the simocracy.org default banner. Reject non-https schemes
|
|
1525
|
+
// (data:, javascript:, file://) defensively even though the only
|
|
1526
|
+
// real downstream consumer is the webapp's <Image> component.
|
|
1527
|
+
let imageRef: { $type: "org.hypercerts.defs#uri"; uri: string };
|
|
1528
|
+
if (imageUri !== undefined) {
|
|
1529
|
+
const trimmed = imageUri.trim();
|
|
1530
|
+
if (!/^https:\/\//i.test(trimmed)) {
|
|
1531
|
+
throw new Error(
|
|
1532
|
+
`imageUri must be an https URL (got "${trimmed}"). Pass an https URL, or omit imageUri to use the default Simocracy banner.`,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
imageRef = { $type: "org.hypercerts.defs#uri", uri: trimmed };
|
|
1536
|
+
} else {
|
|
1537
|
+
imageRef = { $type: "org.hypercerts.defs#uri", uri: DEFAULT_PROPOSAL_BANNER_URI };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Append the budget block (if any) to the user-authored description,
|
|
1541
|
+
// exactly the same way simocracy.org's ProposalFormDialog does.
|
|
1542
|
+
const baseDescription = description?.trim() ?? "";
|
|
1543
|
+
const finalDescription = budgetItems
|
|
1544
|
+
? appendBudgetToDescription(baseDescription, budgetItems)
|
|
1545
|
+
: baseDescription;
|
|
1546
|
+
|
|
1547
|
+
// Build contributors in the lexicon shape:
|
|
1548
|
+
// `Array<{ contributorIdentity: string }>`. Drop blank entries.
|
|
1549
|
+
const contributorRecords =
|
|
1550
|
+
contributors && contributors.length > 0
|
|
1551
|
+
? contributors
|
|
1552
|
+
.map((c) => c.trim())
|
|
1553
|
+
.filter((c) => c.length > 0)
|
|
1554
|
+
.map((contributorIdentity) => ({ contributorIdentity }))
|
|
1555
|
+
: undefined;
|
|
1556
|
+
|
|
1557
|
+
let proposal;
|
|
1558
|
+
try {
|
|
1559
|
+
proposal = await createProposal({
|
|
1560
|
+
agent: pdsAgent,
|
|
1561
|
+
did: auth.did,
|
|
1562
|
+
title,
|
|
1563
|
+
shortDescription,
|
|
1564
|
+
description: finalDescription || undefined,
|
|
1565
|
+
workScope: workScope?.trim() || undefined,
|
|
1566
|
+
contributors:
|
|
1567
|
+
contributorRecords && contributorRecords.length > 0
|
|
1568
|
+
? contributorRecords
|
|
1569
|
+
: undefined,
|
|
1570
|
+
image: imageRef,
|
|
1571
|
+
});
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
throw new Error(`Proposal write failed: ${(err as Error).message}`);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
let sidecarUri: string | undefined;
|
|
1577
|
+
let sidecarWarning: string | undefined;
|
|
1578
|
+
try {
|
|
1579
|
+
const history = await createProposalHistory({
|
|
1580
|
+
agent: pdsAgent,
|
|
1581
|
+
did: auth.did,
|
|
1582
|
+
proposalUri: proposal.uri,
|
|
1583
|
+
proposalTitle: title,
|
|
1584
|
+
simUri: loadedSim.uri,
|
|
1585
|
+
simName: loadedSim.name,
|
|
1586
|
+
content: finalDescription || shortDescription,
|
|
1587
|
+
});
|
|
1588
|
+
sidecarUri = history.uri;
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
// Don't roll back โ the proposal is already on the user's PDS.
|
|
1591
|
+
// The sidecar can be re-written later. Surface the warning so the
|
|
1592
|
+
// LLM can decide whether to retry.
|
|
1593
|
+
sidecarWarning = `Sim-attribution sidecar failed: ${(err as Error).message}`;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const lines = [
|
|
1597
|
+
`Submitted proposal as ${loadedSim.name}${loadedSim.handle ? ` (@${loadedSim.handle})` : ""}:`,
|
|
1598
|
+
` title: ${title}`,
|
|
1599
|
+
` proposal URI: ${proposal.uri}`,
|
|
1600
|
+
` image: ${imageRef.uri}`,
|
|
1601
|
+
];
|
|
1602
|
+
if (sidecarUri) {
|
|
1603
|
+
lines.push(` attribution: ${sidecarUri} (org.simocracy.history sidecar)`);
|
|
1604
|
+
} else if (sidecarWarning) {
|
|
1605
|
+
lines.push(` WARNING: ${sidecarWarning}`);
|
|
1606
|
+
lines.push(
|
|
1607
|
+
` The proposal is posted but will appear unattributed until a history sidecar is written.`,
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
return {
|
|
1611
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
1612
|
+
details: {
|
|
1613
|
+
proposalUri: proposal.uri,
|
|
1614
|
+
proposalRkey: proposal.rkey,
|
|
1615
|
+
proposalCid: proposal.cid,
|
|
1616
|
+
title,
|
|
1617
|
+
shortDescription,
|
|
1618
|
+
imageUri: imageRef.uri,
|
|
1619
|
+
workScope: workScope?.trim() || undefined,
|
|
1620
|
+
contributors: contributorRecords,
|
|
1621
|
+
budgetItemCount: budgetItems?.length ?? 0,
|
|
1622
|
+
simUri: loadedSim.uri,
|
|
1623
|
+
simName: loadedSim.name,
|
|
1624
|
+
sidecarUri,
|
|
1625
|
+
sidecarWarning,
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
},
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1313
1631
|
// -------------------------------------------------------------------------
|
|
1314
1632
|
// Tool: simocracy_lookup_record
|
|
1315
1633
|
//
|
|
@@ -1822,6 +2140,19 @@ async function runLoadFlow(
|
|
|
1822
2140
|
ctx: ExtensionCommandContext,
|
|
1823
2141
|
arg: string,
|
|
1824
2142
|
): Promise<void> {
|
|
2143
|
+
// Defense-in-depth: if a reserved subcommand keyword somehow ends up
|
|
2144
|
+
// here (e.g. dispatcher regression, exotic input that bypassed the
|
|
2145
|
+
// case + zero-width normalization in the `/sim` handler), refuse to
|
|
2146
|
+
// search the indexer for it. Otherwise the user sees a misleading
|
|
2147
|
+
// `Searching for "login"โฆ` followed by an indexer-fetch error.
|
|
2148
|
+
const argTrimmed = arg.trim();
|
|
2149
|
+
if (RESERVED_SUBCOMMANDS.has(argTrimmed.toLowerCase())) {
|
|
2150
|
+
ctx.ui.notify(
|
|
2151
|
+
`\`${argTrimmed}\` is a reserved subcommand. Did you mean \`/sim ${argTrimmed.toLowerCase()}\`? Run \`/sim help\` for the full list.`,
|
|
2152
|
+
"error",
|
|
2153
|
+
);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
1825
2156
|
ctx.ui.notify(`Searching for "${arg}"โฆ`, "info");
|
|
1826
2157
|
let matches: SimMatch[] = [];
|
|
1827
2158
|
if (arg.startsWith("at://")) {
|
package/src/persona.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* normal `/sim` chat and with the `simocracy_chat` tool.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { getSimocracySkillUrl } from "./simocracy.ts";
|
|
11
|
+
|
|
10
12
|
export interface LoadedSim {
|
|
11
13
|
uri: string;
|
|
12
14
|
did: string;
|
|
@@ -55,6 +57,16 @@ export interface LoadedSim {
|
|
|
55
57
|
/** Native PNG height in pixels. */
|
|
56
58
|
heightPx: number;
|
|
57
59
|
};
|
|
60
|
+
/** Fetched contents of `https://www.simocracy.org/skill.md` at
|
|
61
|
+
* sim-load time, when the fetch succeeded. Appended verbatim to
|
|
62
|
+
* the persona prompt by `buildSimPrompt`. Kept on the sim so a
|
|
63
|
+
* re-render or a future `simocracy_chat` call uses the same
|
|
64
|
+
* content the original load used. */
|
|
65
|
+
skillMd?: string;
|
|
66
|
+
/** Reason the skill.md fetch did not run / failed, when relevant.
|
|
67
|
+
* Stored only for diagnostics โ never injected into the persona
|
|
68
|
+
* prompt. Surface optionally via `/sim status`. */
|
|
69
|
+
skillMdError?: string;
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
/**
|
|
@@ -86,6 +98,20 @@ export function buildSimPrompt(sim: LoadedSim): string {
|
|
|
86
98
|
lines.push(`## ${sim.name}'s speaking style`);
|
|
87
99
|
lines.push(sim.style);
|
|
88
100
|
}
|
|
101
|
+
if (sim.skillMd) {
|
|
102
|
+
lines.push(``);
|
|
103
|
+
lines.push(`## Simocracy navigation cheat-sheet`);
|
|
104
|
+
lines.push(
|
|
105
|
+
`Your specific simocracy.org page: https://www.simocracy.org/sims/${sim.did}/${sim.rkey}` +
|
|
106
|
+
(sim.handle ? `\nThe owner's profile: https://www.simocracy.org/profile/${sim.handle}` : ""),
|
|
107
|
+
);
|
|
108
|
+
lines.push(``);
|
|
109
|
+
lines.push(
|
|
110
|
+
`The block below is fetched from ${getSimocracySkillUrl()} on every sim-load and is the canonical reference for URL patterns, indexer endpoints, and common navigation workflows. Treat it as authoritative; when the user asks about you, your gatherings, your proposals, or what other sims have been saying, follow this guide.`,
|
|
111
|
+
);
|
|
112
|
+
lines.push(``);
|
|
113
|
+
lines.push(sim.skillMd);
|
|
114
|
+
}
|
|
89
115
|
lines.push(``);
|
|
90
116
|
lines.push(
|
|
91
117
|
`When the user asks you to use any of pi's tools (read, edit, bash, etc.), you should still use them โ you're ${sim.name} *with access to a developer's terminal*. Just narrate tool use the way ${sim.name} would talk about it.`,
|
package/src/simocracy.ts
CHANGED
|
@@ -345,3 +345,48 @@ export async function resolveHandle(did: string): Promise<string | null> {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
export const SIMOCRACY_INDEXER_URL = DEFAULT_INDEXER_URL;
|
|
348
|
+
|
|
349
|
+
const SIMOCRACY_SKILL_URL =
|
|
350
|
+
process.env.SIMOCRACY_SKILL_URL ?? "https://www.simocracy.org/skill.md";
|
|
351
|
+
|
|
352
|
+
const SKILL_MD_FETCH_TIMEOUT_MS = 4000;
|
|
353
|
+
const SKILL_MD_MAX_BYTES = 64 * 1024;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Fetch the navigation cheat-sheet served at `simocracy.org/skill.md`.
|
|
357
|
+
* Never throws โ returns either `{ text }` on success or `{ error }` on
|
|
358
|
+
* failure / disablement so the caller can record the diagnostic and
|
|
359
|
+
* fall through to the pre-change behaviour (no navigation guidance).
|
|
360
|
+
*/
|
|
361
|
+
export async function fetchSkillMd(): Promise<{ text?: string; error?: string }> {
|
|
362
|
+
if (process.env.SIMOCRACY_SKILL_MD_DISABLED === "1") {
|
|
363
|
+
return { error: "disabled by SIMOCRACY_SKILL_MD_DISABLED=1" };
|
|
364
|
+
}
|
|
365
|
+
const controller = new AbortController();
|
|
366
|
+
const timer = setTimeout(() => controller.abort(), SKILL_MD_FETCH_TIMEOUT_MS);
|
|
367
|
+
try {
|
|
368
|
+
const res = await fetch(SIMOCRACY_SKILL_URL, {
|
|
369
|
+
signal: controller.signal,
|
|
370
|
+
headers: { Accept: "text/markdown, text/plain;q=0.5" },
|
|
371
|
+
});
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
return { error: `${res.status} ${res.statusText}` };
|
|
374
|
+
}
|
|
375
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
376
|
+
const trimmed =
|
|
377
|
+
buf.byteLength > SKILL_MD_MAX_BYTES
|
|
378
|
+
? buf.slice(0, SKILL_MD_MAX_BYTES).toString("utf8") +
|
|
379
|
+
`\n\n<!-- truncated at ${SKILL_MD_MAX_BYTES} bytes -->`
|
|
380
|
+
: buf.toString("utf8");
|
|
381
|
+
return { text: trimmed };
|
|
382
|
+
} catch (err) {
|
|
383
|
+
return { error: (err as Error).message };
|
|
384
|
+
} finally {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Exported for the persona prompt to mention the URL by name. */
|
|
390
|
+
export function getSimocracySkillUrl(): string {
|
|
391
|
+
return SIMOCRACY_SKILL_URL;
|
|
392
|
+
}
|
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
|