pi-simocracy 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +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
+ | `simocracy_post_proposal` | Submit a new funding proposal (`org.hypercerts.claim.activity`) **as the loaded sim**. Writes three records to the user's PDS: the proposal itself, an `org.simocracy.proposalContext` sidecar binding it to a parent gathering or FtC SF floor (required for visibility on `/proposals`), and an `org.simocracy.history` sidecar with `type: "proposal"`. You must pass exactly one of `gatheringUri` (an AT-URI to an `org.simocracy.gathering` — use `simocracy_lookup_record` to resolve a name) or `ftcSfFloor` (1–14). 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. |
43
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. |
44
44
 
45
45
  ---
@@ -69,6 +69,17 @@ Pi calls `simocracy_lookup_record` to find the AT-URI, then `simocracy_post_comm
69
69
 
70
70
  ---
71
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
+
72
83
  ## Sprite rendering
73
84
 
74
85
  Two formats supported:
@@ -12,7 +12,7 @@ subject collection.
12
12
  ## TL;DR
13
13
 
14
14
  When pi submits a proposal on behalf of a loaded sim, it writes
15
- **two** records to the user's PDS:
15
+ **three** records to the user's PDS:
16
16
 
17
17
  1. **`org.hypercerts.claim.activity`** — the proposal itself, in the
18
18
  exact shape simocracy.org's `ProposalFormDialog` already writes
@@ -20,16 +20,27 @@ When pi submits a proposal on behalf of a loaded sim, it writes
20
20
  `workScope` / `contributors` / `image`, `createdAt`). Old readers
21
21
  (Hyperindexer, the existing webapp) see this as a regular
22
22
  user-authored proposal — graceful degradation.
23
- 2. **`org.simocracy.history`** — sidecar record with `type:
23
+ 2. **`org.simocracy.proposalContext`** — sidecar binding the proposal
24
+ to its parent gathering (StrongRef to `org.simocracy.gathering`)
25
+ or to a Frontier Tower SF floor (`floorNumber` integer). Required
26
+ for visibility: post-Phase-5, simocracy.org's `/proposals` feed
27
+ sources its seed set from `org.simocracy.proposalContext` records,
28
+ not from a hashtag scan, so a proposal without this sidecar is
29
+ invisible. Lives in the proposer's PDS so the resolver's tier-1
30
+ (proposer) > tier-2 (facilitator backfill) precedence works.
31
+ 3. **`org.simocracy.history`** — sidecar record with `type:
24
32
  "proposal"`, `subjectUri` pointing at the proposal we just wrote,
25
33
  `simUris[]` / `simNames[]` declaring which sim spoke. New `type`
26
34
  value, but the lexicon's `type` field is free-form string and the
27
35
  indexer already accepts new event types as they appear.
28
36
 
29
- Renderers that understand the join (simocracy.org, when the planned
30
- change lands) will display a sim badge on the proposal card;
37
+ Renderers that understand the history join (simocracy.org, since
38
+ the planned change landed) display a sim badge on the proposal card;
31
39
  renderers that don't keep showing it as a regular user proposal.
32
- **Zero hypercerts lexicon changes. Zero new Simocracy lexicons.**
40
+ **Zero hypercerts lexicon changes.** One new Simocracy lexicon was
41
+ added for proposalContext (see `simocracy-v2/lexicons/org/simocracy/proposalContext.json`)
42
+ so every Simocracy-bound proposal carries a typed back-pointer to
43
+ its parent.
33
44
 
34
45
  ---
35
46
 
@@ -67,6 +78,17 @@ Use it as-is.
67
78
  Implemented by `simocracy_post_proposal` in `src/index.ts`:
68
79
 
69
80
  ```ts
81
+ // Tool input — exactly one of gatheringUri / ftcSfFloor must be set.
82
+ // gatheringUri is fetched ahead of any writes so a typo or unreachable
83
+ // PDS surfaces *before* an orphaned proposal lands in the user's repo.
84
+ const gatheringRef =
85
+ gatheringUri
86
+ ? await getRecordRefFromPds(parsed.did, "org.simocracy.gathering", parsed.rkey)
87
+ : undefined;
88
+ const resolvedContext = gatheringRef
89
+ ? { kind: "gathering" as const, uri: gatheringRef.uri, cid: gatheringRef.cid }
90
+ : { kind: "ftc-sf" as const, floorNumber: ftcSfFloor };
91
+
70
92
  // 1. The proposal — same shape as ProposalFormDialog writes today.
71
93
  const proposal = await createProposal({
72
94
  agent, did,
@@ -78,7 +100,17 @@ const proposal = await createProposal({
78
100
  image: { $type: "org.hypercerts.defs#uri", uri: imageUri ?? DEFAULT_BANNER },
79
101
  });
80
102
 
81
- // 2. The sim-attribution sidecar — required, since attribution is the whole
103
+ // 2. The parent-context sidecar — required for visibility on /proposals.
104
+ // A failure here doesn't roll back the proposal but is surfaced as
105
+ // a `contextSidecarWarning` so the LLM can retry just the sidecar.
106
+ await createProposalContext({
107
+ agent, did,
108
+ proposalUri: proposal.uri,
109
+ proposalCid: proposal.cid,
110
+ context: resolvedContext,
111
+ });
112
+
113
+ // 3. The sim-attribution sidecar — required, since attribution is the whole
82
114
  // point of this tool.
83
115
  await createProposalHistory({
84
116
  agent, did,
@@ -190,9 +222,12 @@ chat / hearing / sprocess events — no new indexer queries needed.
190
222
  ## Status
191
223
 
192
224
  - ✅ 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.
225
+ - Renderer changes shipped in simocracy-v2
226
+ - `org.simocracy.proposalContext` lexicon shipped (1 new Simocracy lexicon, 0 hypercerts changes)
227
+
228
+ The proposal + proposalContext + history triple is being written
229
+ today. Every pi-authored proposal carries both the sim badge
230
+ (history sidecar) and the parent-gathering binding
231
+ (proposalContext sidecar), so it surfaces correctly on
232
+ `simocracy.org/proposals` and under its parent gathering page — no
233
+ migration, no backfill needed for new submissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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
@@ -95,8 +95,10 @@ import {
95
95
  fetchAgentsForSim,
96
96
  fetchStyleForSim,
97
97
  fetchBlob,
98
+ fetchSkillMd,
98
99
  resolveHandle,
99
100
  parseAtUri,
101
+ getRecordRefFromPds,
100
102
  type AgentsRecord,
101
103
  type SimMatch,
102
104
  type StyleRecord,
@@ -128,6 +130,7 @@ import {
128
130
  createComment,
129
131
  createCommentHistory,
130
132
  createProposal,
133
+ createProposalContext,
131
134
  createProposalHistory,
132
135
  createStyle,
133
136
  findRkeyForSim,
@@ -136,6 +139,7 @@ import {
136
139
  NotSimOwnerError,
137
140
  updateAgents,
138
141
  updateStyle,
142
+ type ProposalContextTarget,
139
143
  } from "./writes.ts";
140
144
 
141
145
  // ---------------------------------------------------------------------------
@@ -533,12 +537,14 @@ async function loadSimByName(query: string): Promise<{
533
537
  }
534
538
 
535
539
  async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
536
- // Fetch agents (constitution), style, sprite ANSI + handle in parallel.
537
- const [agents, style, sprite, handle] = await Promise.all([
540
+ // Fetch agents (constitution), style, sprite ANSI, handle, and the
541
+ // simocracy.org navigation cheat-sheet in parallel.
542
+ const [agents, style, sprite, handle, skill] = await Promise.all([
538
543
  fetchAgentsForSim(match.uri).catch(() => null) as Promise<AgentsRecord | null>,
539
544
  fetchStyleForSim(match.uri).catch(() => null) as Promise<StyleRecord | null>,
540
545
  renderSprite(match).catch(() => null),
541
546
  resolveHandle(match.did).catch(() => null),
547
+ fetchSkillMd(),
542
548
  ]);
543
549
 
544
550
  return {
@@ -566,6 +572,8 @@ async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
566
572
  shortDescription: agents?.shortDescription,
567
573
  description: agents?.description,
568
574
  style: style?.description,
575
+ skillMd: skill.text,
576
+ skillMdError: skill.text ? undefined : skill.error,
569
577
  };
570
578
  }
571
579
 
@@ -782,6 +790,25 @@ const PostProposalToolParams = Type.Object({
782
790
  "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
791
  }),
784
792
  ),
793
+ // Parent-context binding — effectively required for the proposal to
794
+ // be discoverable on simocracy.org (post-Phase-5 the read paths only
795
+ // surface proposals that have a sidecar). Exactly one of the two must
796
+ // be passed; the execute handler enforces that.
797
+ gatheringUri: Type.Optional(
798
+ Type.String({
799
+ description:
800
+ "AT-URI of the parent gathering this proposal belongs to (an `org.simocracy.gathering` record). Pass exactly one of `gatheringUri` or `ftcSfFloor`. Get the URI by calling `simocracy_lookup_record` first — the LLM should resolve a fuzzy gathering name to a real AT-URI before submitting. Without a parent context, the proposal will be invisible on simocracy.org's `/proposals` feed.",
801
+ pattern: "^at://[^/]+/org\\.simocracy\\.gathering/[^/]+$",
802
+ }),
803
+ ),
804
+ ftcSfFloor: Type.Optional(
805
+ Type.Integer({
806
+ description:
807
+ "Frontier Tower SF floor number this proposal belongs to (1–14, with the static configuration in simocracy-v2's `lib/ftc-sf-data.ts`). Pass exactly one of `gatheringUri` or `ftcSfFloor`. Use this for proposals submitted to the Frontier Tower SF agentic funding experiment instead of a regular gathering.",
808
+ minimum: 1,
809
+ maximum: 14,
810
+ }),
811
+ ),
785
812
  });
786
813
 
787
814
  const LookupRecordToolParams = Type.Object({
@@ -1487,11 +1514,21 @@ export default async function simocracy(pi: ExtensionAPI) {
1487
1514
  name: "simocracy_post_proposal",
1488
1515
  label: "Submit a Simocracy proposal as the loaded sim",
1489
1516
  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.",
1517
+ "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 THREE records to the user's PDS: (1) the proposal itself, (2) an `org.simocracy.proposalContext` sidecar binding the proposal to its parent gathering / FtC SF floor (required for the proposal to appear in simocracy.org's `/proposals` feed), and (3) 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\". You MUST pass exactly one of `gatheringUri` (to bind the proposal to a gathering) or `ftcSfFloor` (to bind to a Frontier Tower SF floor); call `simocracy_lookup_record` first to resolve a gathering name to its AT-URI. 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
1518
  parameters: PostProposalToolParams,
1492
1519
  async execute(
1493
1520
  _id,
1494
- { title, shortDescription, description, workScope, contributors, budgetItems, imageUri },
1521
+ {
1522
+ title,
1523
+ shortDescription,
1524
+ description,
1525
+ workScope,
1526
+ contributors,
1527
+ budgetItems,
1528
+ imageUri,
1529
+ gatheringUri,
1530
+ ftcSfFloor,
1531
+ },
1495
1532
  ) {
1496
1533
  if (!loadedSim) {
1497
1534
  throw new Error(
@@ -1515,6 +1552,64 @@ export default async function simocracy(pi: ExtensionAPI) {
1515
1552
  throw new Error(`ATProto auth failed: ${(err as Error).message}`);
1516
1553
  }
1517
1554
 
1555
+ // Parent-context validation — exactly one of gatheringUri / ftcSfFloor.
1556
+ // Without one, the proposal is invisible on /proposals, so we require
1557
+ // it up-front rather than letting the LLM ship orphaned proposals.
1558
+ const hasGathering = gatheringUri !== undefined;
1559
+ const hasFloor = ftcSfFloor !== undefined;
1560
+ if (hasGathering && hasFloor) {
1561
+ throw new Error(
1562
+ "Pass exactly one of `gatheringUri` (for a gathering) or `ftcSfFloor` (for FtC SF), not both.",
1563
+ );
1564
+ }
1565
+ if (!hasGathering && !hasFloor) {
1566
+ throw new Error(
1567
+ "A parent context is required: pass `gatheringUri` (an AT-URI to an `org.simocracy.gathering` — use `simocracy_lookup_record` to resolve a name) or `ftcSfFloor` (a Frontier Tower SF floor number 1–14). Without one, the proposal will be invisible on simocracy.org's /proposals feed.",
1568
+ );
1569
+ }
1570
+
1571
+ // Resolve gathering URI → StrongRef now (before any writes) so a
1572
+ // typo or unreachable PDS surfaces *before* we put a half-orphaned
1573
+ // proposal record into the user's repo.
1574
+ let resolvedContext: ProposalContextTarget;
1575
+ if (hasGathering) {
1576
+ let gatheringDid: string;
1577
+ let gatheringRkey: string;
1578
+ try {
1579
+ const parsed = parseAtUri(gatheringUri!);
1580
+ if (parsed.collection !== "org.simocracy.gathering") {
1581
+ throw new Error(
1582
+ `gatheringUri must point at an org.simocracy.gathering record (got ${parsed.collection}).`,
1583
+ );
1584
+ }
1585
+ gatheringDid = parsed.did;
1586
+ gatheringRkey = parsed.rkey;
1587
+ } catch (err) {
1588
+ throw new Error(
1589
+ `gatheringUri is not a valid AT-URI: ${(err as Error).message}`,
1590
+ );
1591
+ }
1592
+ let gatheringRef: { uri: string; cid: string };
1593
+ try {
1594
+ gatheringRef = await getRecordRefFromPds(
1595
+ gatheringDid,
1596
+ "org.simocracy.gathering",
1597
+ gatheringRkey,
1598
+ );
1599
+ } catch (err) {
1600
+ throw new Error(
1601
+ `Couldn't fetch gathering record from owner's PDS — verify the URI is correct and the gathering still exists. ${(err as Error).message}`,
1602
+ );
1603
+ }
1604
+ resolvedContext = {
1605
+ kind: "gathering",
1606
+ uri: gatheringRef.uri,
1607
+ cid: gatheringRef.cid,
1608
+ };
1609
+ } else {
1610
+ resolvedContext = { kind: "ftc-sf", floorNumber: ftcSfFloor! };
1611
+ }
1612
+
1518
1613
  // Resolve the cover image — either an LLM-supplied https URL or
1519
1614
  // the simocracy.org default banner. Reject non-https schemes
1520
1615
  // (data:, javascript:, file://) defensively even though the only
@@ -1568,6 +1663,26 @@ export default async function simocracy(pi: ExtensionAPI) {
1568
1663
  throw new Error(`Proposal write failed: ${(err as Error).message}`);
1569
1664
  }
1570
1665
 
1666
+ // Parent-context sidecar — written FIRST after the proposal so a
1667
+ // visibility failure is loud and immediate. If this fails the
1668
+ // proposal is already on the user's PDS but won't surface on
1669
+ // /proposals; we surface a warning so the LLM can retry just this
1670
+ // sidecar instead of re-submitting the whole proposal.
1671
+ let contextSidecarUri: string | undefined;
1672
+ let contextSidecarWarning: string | undefined;
1673
+ try {
1674
+ const ctxWrite = await createProposalContext({
1675
+ agent: pdsAgent,
1676
+ did: auth.did,
1677
+ proposalUri: proposal.uri,
1678
+ proposalCid: proposal.cid,
1679
+ context: resolvedContext,
1680
+ });
1681
+ contextSidecarUri = ctxWrite.uri;
1682
+ } catch (err) {
1683
+ contextSidecarWarning = `Parent-context sidecar failed: ${(err as Error).message}`;
1684
+ }
1685
+
1571
1686
  let sidecarUri: string | undefined;
1572
1687
  let sidecarWarning: string | undefined;
1573
1688
  try {
@@ -1588,12 +1703,25 @@ export default async function simocracy(pi: ExtensionAPI) {
1588
1703
  sidecarWarning = `Sim-attribution sidecar failed: ${(err as Error).message}`;
1589
1704
  }
1590
1705
 
1706
+ const parentLabel =
1707
+ resolvedContext.kind === "gathering"
1708
+ ? `gathering ${resolvedContext.uri}`
1709
+ : `FtC SF floor ${resolvedContext.floorNumber}`;
1591
1710
  const lines = [
1592
1711
  `Submitted proposal as ${loadedSim.name}${loadedSim.handle ? ` (@${loadedSim.handle})` : ""}:`,
1593
1712
  ` title: ${title}`,
1594
1713
  ` proposal URI: ${proposal.uri}`,
1714
+ ` parent: ${parentLabel}`,
1595
1715
  ` image: ${imageRef.uri}`,
1596
1716
  ];
1717
+ if (contextSidecarUri) {
1718
+ lines.push(` context: ${contextSidecarUri} (org.simocracy.proposalContext sidecar)`);
1719
+ } else if (contextSidecarWarning) {
1720
+ lines.push(` WARNING: ${contextSidecarWarning}`);
1721
+ lines.push(
1722
+ ` The proposal is posted but will be invisible on /proposals until a proposalContext sidecar is written.`,
1723
+ );
1724
+ }
1597
1725
  if (sidecarUri) {
1598
1726
  lines.push(` attribution: ${sidecarUri} (org.simocracy.history sidecar)`);
1599
1727
  } else if (sidecarWarning) {
@@ -1616,6 +1744,12 @@ export default async function simocracy(pi: ExtensionAPI) {
1616
1744
  budgetItemCount: budgetItems?.length ?? 0,
1617
1745
  simUri: loadedSim.uri,
1618
1746
  simName: loadedSim.name,
1747
+ parent:
1748
+ resolvedContext.kind === "gathering"
1749
+ ? { kind: "gathering", uri: resolvedContext.uri, cid: resolvedContext.cid }
1750
+ : { kind: "ftc-sf", floorNumber: resolvedContext.floorNumber },
1751
+ contextSidecarUri,
1752
+ contextSidecarWarning,
1619
1753
  sidecarUri,
1620
1754
  sidecarWarning,
1621
1755
  },
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
@@ -252,6 +252,34 @@ export async function getRecordFromPds<T>(did: string, collection: string, rkey:
252
252
  return json.value as T;
253
253
  }
254
254
 
255
+ /**
256
+ * Read a record's StrongRef ({uri, cid}) directly from the owner's PDS.
257
+ *
258
+ * Same wire call as `getRecordFromPds` but returns the metadata needed to
259
+ * reference the record from another record (e.g. an `org.simocracy.proposalContext`
260
+ * sidecar's `subject` or `context.gathering` field). The body is intentionally
261
+ * dropped — callers that need it should use `getRecordFromPds` instead.
262
+ */
263
+ export async function getRecordRefFromPds(
264
+ did: string,
265
+ collection: string,
266
+ rkey: string,
267
+ ): Promise<{ uri: string; cid: string }> {
268
+ const pds = await resolvePds(did);
269
+ const url =
270
+ `${pds.replace(/\/+$/, "")}/xrpc/com.atproto.repo.getRecord` +
271
+ `?repo=${encodeURIComponent(did)}` +
272
+ `&collection=${encodeURIComponent(collection)}` +
273
+ `&rkey=${encodeURIComponent(rkey)}`;
274
+ const res = await fetch(url);
275
+ if (!res.ok) throw new Error(`PDS getRecord failed: ${res.status}`);
276
+ const json = (await res.json()) as { uri?: string; cid?: string };
277
+ if (!json.uri || !json.cid) {
278
+ throw new Error(`PDS getRecord returned no uri/cid: ${url}`);
279
+ }
280
+ return { uri: json.uri, cid: json.cid };
281
+ }
282
+
255
283
  /** List records by paging com.atproto.repo.listRecords on a PDS. */
256
284
  export async function listRecordsFromPds<T>(did: string, collection: string): Promise<Array<{ uri: string; cid: string; value: T }>> {
257
285
  const pds = await resolvePds(did);
@@ -345,3 +373,48 @@ export async function resolveHandle(did: string): Promise<string | null> {
345
373
  }
346
374
 
347
375
  export const SIMOCRACY_INDEXER_URL = DEFAULT_INDEXER_URL;
376
+
377
+ const SIMOCRACY_SKILL_URL =
378
+ process.env.SIMOCRACY_SKILL_URL ?? "https://www.simocracy.org/skill.md";
379
+
380
+ const SKILL_MD_FETCH_TIMEOUT_MS = 4000;
381
+ const SKILL_MD_MAX_BYTES = 64 * 1024;
382
+
383
+ /**
384
+ * Fetch the navigation cheat-sheet served at `simocracy.org/skill.md`.
385
+ * Never throws — returns either `{ text }` on success or `{ error }` on
386
+ * failure / disablement so the caller can record the diagnostic and
387
+ * fall through to the pre-change behaviour (no navigation guidance).
388
+ */
389
+ export async function fetchSkillMd(): Promise<{ text?: string; error?: string }> {
390
+ if (process.env.SIMOCRACY_SKILL_MD_DISABLED === "1") {
391
+ return { error: "disabled by SIMOCRACY_SKILL_MD_DISABLED=1" };
392
+ }
393
+ const controller = new AbortController();
394
+ const timer = setTimeout(() => controller.abort(), SKILL_MD_FETCH_TIMEOUT_MS);
395
+ try {
396
+ const res = await fetch(SIMOCRACY_SKILL_URL, {
397
+ signal: controller.signal,
398
+ headers: { Accept: "text/markdown, text/plain;q=0.5" },
399
+ });
400
+ if (!res.ok) {
401
+ return { error: `${res.status} ${res.statusText}` };
402
+ }
403
+ const buf = Buffer.from(await res.arrayBuffer());
404
+ const trimmed =
405
+ buf.byteLength > SKILL_MD_MAX_BYTES
406
+ ? buf.slice(0, SKILL_MD_MAX_BYTES).toString("utf8") +
407
+ `\n\n<!-- truncated at ${SKILL_MD_MAX_BYTES} bytes -->`
408
+ : buf.toString("utf8");
409
+ return { text: trimmed };
410
+ } catch (err) {
411
+ return { error: (err as Error).message };
412
+ } finally {
413
+ clearTimeout(timer);
414
+ }
415
+ }
416
+
417
+ /** Exported for the persona prompt to mention the URL by name. */
418
+ export function getSimocracySkillUrl(): string {
419
+ return SIMOCRACY_SKILL_URL;
420
+ }
package/src/writes.ts CHANGED
@@ -130,6 +130,7 @@ const COLLECTION_STYLE = "org.simocracy.style";
130
130
  const COLLECTION_COMMENT = "org.impactindexer.review.comment";
131
131
  const COLLECTION_HISTORY = "org.simocracy.history";
132
132
  const COLLECTION_PROPOSAL = "org.hypercerts.claim.activity";
133
+ const COLLECTION_PROPOSAL_CONTEXT = "org.simocracy.proposalContext";
133
134
 
134
135
  /**
135
136
  * Defense-in-depth: every write helper below verifies the target
@@ -455,6 +456,86 @@ export async function createProposal(opts: {
455
456
  };
456
457
  }
457
458
 
459
+ /**
460
+ * Discriminated parent target for an `org.simocracy.proposalContext`
461
+ * sidecar. Mirrors the lexicon's `context` union (#gatheringContext
462
+ * vs #ftcSfContext); see `simocracy-v2/lexicons/org/simocracy/proposalContext.json`.
463
+ */
464
+ export type ProposalContextTarget =
465
+ | { kind: "gathering"; uri: string; cid: string }
466
+ | { kind: "ftc-sf"; floorNumber: number };
467
+
468
+ /**
469
+ * POST `org.simocracy.proposalContext` (parent-context sidecar).
470
+ *
471
+ * Binds a proposal to its parent container — either an
472
+ * `org.simocracy.gathering` record (via StrongRef) or a static FtC SF
473
+ * floor number. Without this sidecar, simocracy.org's read paths
474
+ * (post-Phase-5) won't surface the proposal on `/proposals` or under
475
+ * the gathering / floor it belongs to. The sidecar is therefore
476
+ * effectively required for any proposal submitted via this tool.
477
+ *
478
+ * Same trust model as `org.simocracy.history` — lives in the
479
+ * proposer's own PDS so the resolver's tier-1 (proposer-PDS) >
480
+ * tier-2 (facilitator-PDS) precedence rule lets a backfill record
481
+ * be silently superseded if the proposer ever re-saves.
482
+ *
483
+ * No sim-ownership precondition: a proposal isn't sim-owned, and
484
+ * the sidecar references the proposal + parent gathering, not the
485
+ * sim. The OAuth precondition ($DID matches signed-in DID and the
486
+ * agent is authenticated) is enforced upstream by
487
+ * `assertCanWriteToSim` at the tool entry point.
488
+ */
489
+ export async function createProposalContext(opts: {
490
+ agent: Agent;
491
+ did: string;
492
+ proposalUri: string;
493
+ proposalCid: string;
494
+ context: ProposalContextTarget;
495
+ }): Promise<{ uri: string; cid: string; rkey: string }> {
496
+ let context: Record<string, unknown>;
497
+ if (opts.context.kind === "gathering") {
498
+ if (!opts.context.uri || !opts.context.cid) {
499
+ throw new Error(
500
+ "Gathering parent context requires both uri and cid (a full StrongRef).",
501
+ );
502
+ }
503
+ context = {
504
+ $type: `${COLLECTION_PROPOSAL_CONTEXT}#gatheringContext`,
505
+ gathering: { uri: opts.context.uri, cid: opts.context.cid },
506
+ };
507
+ } else {
508
+ if (
509
+ !Number.isInteger(opts.context.floorNumber) ||
510
+ opts.context.floorNumber < 1
511
+ ) {
512
+ throw new Error(
513
+ `FtC SF floor number must be a positive integer (got ${opts.context.floorNumber}).`,
514
+ );
515
+ }
516
+ context = {
517
+ $type: `${COLLECTION_PROPOSAL_CONTEXT}#ftcSfContext`,
518
+ floorNumber: opts.context.floorNumber,
519
+ };
520
+ }
521
+ const record = {
522
+ $type: COLLECTION_PROPOSAL_CONTEXT,
523
+ subject: { uri: opts.proposalUri, cid: opts.proposalCid },
524
+ context,
525
+ createdAt: new Date().toISOString(),
526
+ };
527
+ const res = await opts.agent.com.atproto.repo.createRecord({
528
+ repo: opts.did,
529
+ collection: COLLECTION_PROPOSAL_CONTEXT,
530
+ record,
531
+ });
532
+ return {
533
+ uri: res.data.uri,
534
+ cid: res.data.cid,
535
+ rkey: res.data.uri.split("/").pop() ?? "",
536
+ };
537
+ }
538
+
458
539
  /**
459
540
  * Sim-attribution sidecar for a proposal.
460
541
  *