pi-simocracy 0.5.0 → 0.6.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/src/index.ts CHANGED
@@ -48,6 +48,16 @@
48
48
  * style for the loaded sim to the user's
49
49
  * PDS. Requires the user to be signed in
50
50
  * via /sim login AND to own the sim.
51
+ * - `simocracy_post_comment` Write a comment on a proposal /
52
+ * gathering / sim / decision / parent
53
+ * comment, attributed to the loaded sim
54
+ * via an `org.simocracy.history` sidecar
55
+ * (no impactindexer lexicon changes).
56
+ * Requires /sim login + ownership.
57
+ * - `simocracy_lookup_record` Look up a sim / proposal / gathering /
58
+ * decision / comment by AT-URI or fuzzy
59
+ * name and return its details + comment
60
+ * subtree with sim attribution joined.
51
61
  *
52
62
  * Note on /login: pi itself ships a built-in `/login` for Anthropic OAuth.
53
63
  * To avoid the collision (and to make it explicit you're signing into
@@ -69,6 +79,7 @@ import {
69
79
  type ImageTheme,
70
80
  type TUI,
71
81
  } from "@mariozechner/pi-tui";
82
+ import { AnimatedImage } from "./animated-image.ts";
72
83
  import { Type } from "typebox";
73
84
 
74
85
  import {
@@ -83,6 +94,13 @@ import {
83
94
  type SimMatch,
84
95
  type StyleRecord,
85
96
  } from "./simocracy.ts";
97
+ import {
98
+ bestNameForRecord,
99
+ lookupRecord,
100
+ type LookupKind,
101
+ type LookupResult,
102
+ type ResolvedComment,
103
+ } from "./lookup.ts";
86
104
  import {
87
105
  decodePng,
88
106
  renderRgbaToAnsi,
@@ -100,6 +118,8 @@ import { readAuth } from "./auth/storage.ts";
100
118
  import {
101
119
  assertCanWriteToSim,
102
120
  createAgents,
121
+ createComment,
122
+ createCommentHistory,
103
123
  createStyle,
104
124
  findRkeyForSim,
105
125
  getAuthenticatedAgent,
@@ -144,9 +164,10 @@ let justUnloaded: string | null = null;
144
164
  let capturedTui: TUI | null = null;
145
165
 
146
166
  interface ActiveAnimation {
147
- /** Identifies which loaded-sim message owns this animation. The renderer
148
- * compares against `details.animationKey` to decide whether to use the
149
- * current frame or the static idle PNG. */
167
+ /** Identifies which loaded-sim message owns this animation. The
168
+ * renderer compares against `details.animationKey` to decide
169
+ * whether to mount the live `AnimatedImage` for this message or a
170
+ * static idle frame. */
150
171
  key: string;
151
172
  /** Stable Kitty image ID so frame transmissions replace the previous one
152
173
  * instead of stacking. Allocated once per sim load. */
@@ -156,10 +177,17 @@ interface ActiveAnimation {
156
177
  /** Frame width / height in pixels (uniform across frames). */
157
178
  widthPx: number;
158
179
  heightPx: number;
159
- /** Currently displayed frame index. */
160
- currentFrame: number;
161
- /** Active setInterval handle so we can clear it on unload / reload. */
162
- intervalId: ReturnType<typeof setInterval> | null;
180
+ /** Display rate. */
181
+ fps: number;
182
+ /**
183
+ * The live animated component. Created lazily inside the message
184
+ * renderer the first time it's asked for this `key` — we need a
185
+ * TUI handle to construct one, and the renderer is the natural
186
+ * place that has access (via `capturedTui`). Disposed when a new
187
+ * sim takes over the active-animation slot or when the sim is
188
+ * unloaded.
189
+ */
190
+ component: AnimatedImage | null;
163
191
  }
164
192
  let currentAnimation: ActiveAnimation | null = null;
165
193
 
@@ -187,15 +215,18 @@ const spriteWidthCells = (() => {
187
215
  })();
188
216
 
189
217
  function stopCurrentAnimation(): void {
190
- if (currentAnimation?.intervalId !== null && currentAnimation?.intervalId !== undefined) {
191
- clearInterval(currentAnimation.intervalId);
218
+ if (currentAnimation?.component) {
219
+ currentAnimation.component.dispose();
192
220
  }
193
221
  currentAnimation = null;
194
222
  }
195
223
 
196
- function startAnimationFor(key: string, frames: { pngBase64: string[]; fps: number; widthPx: number; heightPx: number }): void {
197
- // Always replace any prior animation — only the most recent loaded-sim
198
- // message animates.
224
+ function startAnimationFor(
225
+ key: string,
226
+ frames: { pngBase64: string[]; fps: number; widthPx: number; heightPx: number },
227
+ ): void {
228
+ // Always replace any prior animation — only the most recent
229
+ // loaded-sim message animates.
199
230
  stopCurrentAnimation();
200
231
  if (!animationEnabled || frames.pngBase64.length < 2) return;
201
232
  currentAnimation = {
@@ -204,16 +235,12 @@ function startAnimationFor(key: string, frames: { pngBase64: string[]; fps: numb
204
235
  frames: frames.pngBase64,
205
236
  widthPx: frames.widthPx,
206
237
  heightPx: frames.heightPx,
207
- currentFrame: 0,
208
- intervalId: null,
238
+ fps: frames.fps,
239
+ // Lazily instantiated by the message renderer the first time it
240
+ // sees this `key` — we don't have a TUI handle here, only inside
241
+ // the setWidget factory.
242
+ component: null,
209
243
  };
210
- const intervalMs = Math.max(1000 / frames.fps, 60); // clamp to ≆16 fps max
211
- currentAnimation.intervalId = setInterval(() => {
212
- if (!currentAnimation) return;
213
- currentAnimation.currentFrame =
214
- (currentAnimation.currentFrame + 1) % currentAnimation.frames.length;
215
- capturedTui?.requestRender();
216
- }, intervalMs);
217
244
  }
218
245
 
219
246
  // ---------------------------------------------------------------------------
@@ -597,6 +624,50 @@ const UpdateSimToolParams = Type.Object({
597
624
  ),
598
625
  });
599
626
 
627
+ const PostCommentToolParams = Type.Object({
628
+ subjectUri: Type.String({
629
+ description:
630
+ "AT-URI of the record to comment on. Accepts proposals (org.hypercerts.claim.activity), gatherings (org.simocracy.gathering), sims (org.simocracy.sim), decisions (org.simocracy.decision), or another comment URI for a nested reply. Get one by calling simocracy_lookup_record first if you don't already have it.",
631
+ minLength: 1,
632
+ }),
633
+ text: Type.String({
634
+ description:
635
+ "The comment body. Plain text up to ~5000 chars. Write it as the loaded sim would speak \u2014 the sim's persona is already injected into your system prompt, so just say what they'd say.",
636
+ minLength: 1,
637
+ maxLength: 5000,
638
+ }),
639
+ });
640
+
641
+ const LookupRecordToolParams = Type.Object({
642
+ query: Type.String({
643
+ description:
644
+ "AT-URI (at://did/collection/rkey) or fuzzy name to look up. AT-URI fetches the exact record from its owner's PDS; a name searches the indexer.",
645
+ minLength: 1,
646
+ }),
647
+ kind: Type.Optional(
648
+ Type.Union(
649
+ [
650
+ Type.Literal("auto"),
651
+ Type.Literal("sim"),
652
+ Type.Literal("proposal"),
653
+ Type.Literal("gathering"),
654
+ Type.Literal("decision"),
655
+ Type.Literal("comment"),
656
+ ],
657
+ {
658
+ description:
659
+ "Restrict the search to one record kind. `auto` (default) searches sims + proposals + gatherings + decisions in parallel and returns the best match. `comment` is only meaningful with an AT-URI query \u2014 comments aren't full-text searchable.",
660
+ },
661
+ ),
662
+ ),
663
+ withComments: Type.Optional(
664
+ Type.Boolean({
665
+ description:
666
+ "Include the full comment subtree in the response, with sim attribution joined from org.simocracy.history sidecars. Default true. Set false for a smaller, record-only response.",
667
+ }),
668
+ ),
669
+ });
670
+
600
671
  export default async function simocracy(pi: ExtensionAPI) {
601
672
  // -------------------------------------------------------------------------
602
673
  // System prompt injection — every turn the loaded sim's persona is appended.
@@ -668,47 +739,57 @@ export default async function simocracy(pi: ExtensionAPI) {
668
739
  const imageTheme: ImageTheme = {
669
740
  fallbackColor: theme.fg("dim", "") ? (s: string) => theme.fg("dim", s) : (s: string) => s,
670
741
  };
671
- // Animation handoff: if there's an active animation AND it's
672
- // for this exact message (matching animationKey), pull the
673
- // current frame's PNG + the stable Kitty image ID. The image
674
- // ID makes successive frame transmissions replace each other
675
- // in place rather than stacking. For everything else (older
676
- // loaded-sim messages, sims without animation frames) we use
677
- // the static idle PNG with no image ID, so they freeze
678
- // gracefully on the first frame.
679
- let pngBase64 = details.spritePngBase64;
680
- let imageOptions: { maxWidthCells: number; imageId?: number } = {
681
- maxWidthCells: spriteWidthCells,
682
- };
683
- let imageDims = {
684
- widthPx: details.spritePngWidth ?? 0,
685
- heightPx: details.spritePngHeight ?? 0,
686
- };
687
- if (
742
+ const box = new Box(0, 0);
743
+
744
+ // If this message owns the active animation slot, mount a live
745
+ // `AnimatedImage` (cycles frames, owns its own setInterval). We
746
+ // cache the component on `currentAnimation.component` so we
747
+ // don't spawn a fresh timer on every re-render of the message
748
+ // (pi-tui calls the renderer again on expand/collapse, theme
749
+ // change, etc.).
750
+ //
751
+ // For every other case older loaded-sim messages whose key
752
+ // doesn't match, sims without animation frames, or animation
753
+ // disabled via env — we mount a static `Image` of the idle
754
+ // frame, which freezes gracefully.
755
+ const isActiveAnimation =
688
756
  currentAnimation &&
689
- details.animationKey &&
690
- currentAnimation.key === details.animationKey
691
- ) {
692
- pngBase64 = currentAnimation.frames[currentAnimation.currentFrame] ?? pngBase64;
693
- imageOptions = {
694
- maxWidthCells: spriteWidthCells,
695
- imageId: currentAnimation.imageId,
696
- };
697
- imageDims = { widthPx: currentAnimation.widthPx, heightPx: currentAnimation.heightPx };
757
+ capturedTui !== null &&
758
+ details.animationKey !== undefined &&
759
+ currentAnimation.key === details.animationKey;
760
+ if (isActiveAnimation) {
761
+ if (!currentAnimation!.component) {
762
+ currentAnimation!.component = new AnimatedImage({
763
+ frames: currentAnimation!.frames,
764
+ dimensions: {
765
+ widthPx: currentAnimation!.widthPx,
766
+ heightPx: currentAnimation!.heightPx,
767
+ },
768
+ maxWidthCells: spriteWidthCells,
769
+ imageId: currentAnimation!.imageId,
770
+ fps: currentAnimation!.fps,
771
+ tui: capturedTui!,
772
+ });
773
+ }
774
+ box.addChild(currentAnimation!.component);
775
+ } else {
776
+ // Source PNG is at full native resolution (192×208 for codex
777
+ // pets, 32×32 for pipoya). The terminal scales it down to
778
+ // `spriteWidthCells` cells wide on display — we never
779
+ // pre-downsample on our side, so quality is preserved.
780
+ box.addChild(
781
+ new Image(
782
+ details.spritePngBase64,
783
+ "image/png",
784
+ imageTheme,
785
+ { maxWidthCells: spriteWidthCells },
786
+ {
787
+ widthPx: details.spritePngWidth ?? 0,
788
+ heightPx: details.spritePngHeight ?? 0,
789
+ },
790
+ ),
791
+ );
698
792
  }
699
- // Source PNG is at full native resolution (192×208 for codex
700
- // pets, 32×32 for pipoya). The terminal scales it down to
701
- // `spriteWidthCells` cells wide on display — we never
702
- // pre-downsample on our side, so quality is preserved.
703
- const image = new Image(
704
- pngBase64,
705
- "image/png",
706
- imageTheme,
707
- imageOptions,
708
- imageDims,
709
- );
710
- const box = new Box(0, 0);
711
- box.addChild(image);
712
793
  box.addChild(new Text(details.bioText, 0, 0));
713
794
  return box;
714
795
  }
@@ -1097,8 +1178,490 @@ export default async function simocracy(pi: ExtensionAPI) {
1097
1178
  };
1098
1179
  },
1099
1180
  });
1181
+
1182
+ // -------------------------------------------------------------------------
1183
+ // Tool: simocracy_post_comment
1184
+ //
1185
+ // Writes a comment on behalf of the currently loaded sim. Two records
1186
+ // are written to the user's PDS:
1187
+ //
1188
+ // 1. org.impactindexer.review.comment the comment itself (same wire
1189
+ // shape simocracy.org's webapp writes today, so it threads + renders
1190
+ // identically there).
1191
+ // 2. org.simocracy.history sidecar with type="comment",
1192
+ // simUris=[loadedSim], subjectUri=<comment uri>. Renderers that
1193
+ // understand the join (simocracy.org, when the planned change
1194
+ // lands) display a sim badge; renderers that don't see a regular
1195
+ // user comment — graceful degradation, zero lexicon changes.
1196
+ //
1197
+ // See `docs/SIM_AUTHORED_COMMENTS.md` for the full design.
1198
+ // -------------------------------------------------------------------------
1199
+ pi.registerTool({
1200
+ name: "simocracy_post_comment",
1201
+ label: "Post a comment as the loaded Simocracy sim",
1202
+ description:
1203
+ "Post a comment on a Simocracy record (proposal / gathering / sim / decision / another comment) as the currently loaded sim. The comment text should sound like the sim \u2014 their persona is already in your system prompt. Writes the comment to the user's PDS plus an org.simocracy.history sidecar that attributes the comment to the loaded sim (no impactindexer lexicon changes needed). Use this when the user asks the sim to weigh in on something, comment on a proposal, reply to another comment, or leave their opinion. Requires /sim login + ownership of the loaded sim.",
1204
+ parameters: PostCommentToolParams,
1205
+ async execute(_id, { subjectUri, text }) {
1206
+ if (!loadedSim) {
1207
+ throw new Error(
1208
+ "No sim loaded. Call simocracy_load_sim first \u2014 comments are written on behalf of a specific sim.",
1209
+ );
1210
+ }
1211
+ let auth;
1212
+ try {
1213
+ auth = await assertCanWriteToSim(loadedSim, { action: "post a comment as" });
1214
+ } catch (err) {
1215
+ if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
1216
+ throw new Error(err.message);
1217
+ }
1218
+ throw err;
1219
+ }
1220
+ let pdsAgent;
1221
+ try {
1222
+ ({ agent: pdsAgent } = await getAuthenticatedAgent());
1223
+ } catch (err) {
1224
+ if (err instanceof NotSignedInError) throw new Error(err.message);
1225
+ throw new Error(`ATProto auth failed: ${(err as Error).message}`);
1226
+ }
1227
+
1228
+ // Best-effort fetch of the parent record so we can denormalize its
1229
+ // title onto the history sidecar (drives the timeline UX in
1230
+ // simocracy.org). Failure is non-fatal — the comment goes through
1231
+ // either way, the badge just won't carry a title.
1232
+ let parentName: string | undefined;
1233
+ let parentCollection: string | undefined;
1234
+ try {
1235
+ const parsed = parseAtUri(subjectUri);
1236
+ parentCollection = parsed.collection;
1237
+ const { getRecordFromPds } = await import("./simocracy.ts");
1238
+ const parentValue = await getRecordFromPds<Record<string, unknown>>(
1239
+ parsed.did,
1240
+ parsed.collection,
1241
+ parsed.rkey,
1242
+ );
1243
+ parentName = bestNameForRecord(parsed.collection, parentValue);
1244
+ } catch {
1245
+ /* non-fatal — leave parentName undefined */
1246
+ }
1247
+
1248
+ let comment;
1249
+ try {
1250
+ comment = await createComment({
1251
+ agent: pdsAgent,
1252
+ did: auth.did,
1253
+ subjectUri,
1254
+ text,
1255
+ });
1256
+ } catch (err) {
1257
+ throw new Error(`Comment write failed: ${(err as Error).message}`);
1258
+ }
1259
+
1260
+ let attributionUri: string | undefined;
1261
+ let attributionWarning: string | undefined;
1262
+ try {
1263
+ const history = await createCommentHistory({
1264
+ agent: pdsAgent,
1265
+ did: auth.did,
1266
+ commentUri: comment.uri,
1267
+ simUri: loadedSim.uri,
1268
+ simName: loadedSim.name,
1269
+ text,
1270
+ proposalTitle: parentName,
1271
+ parentCollection,
1272
+ parentName,
1273
+ });
1274
+ attributionUri = history.uri;
1275
+ } catch (err) {
1276
+ // Don't fail the whole call — the comment is already on the user's
1277
+ // PDS. The sidecar can be re-written later. Surface the warning so
1278
+ // the LLM can decide whether to retry.
1279
+ attributionWarning = `Sim-attribution sidecar failed: ${(err as Error).message}`;
1280
+ }
1281
+
1282
+ const lines = [
1283
+ `Posted comment as ${loadedSim.name}${loadedSim.handle ? ` (@${loadedSim.handle})` : ""}:`,
1284
+ ` comment URI: ${comment.uri}`,
1285
+ ];
1286
+ if (parentName) lines.push(` on: ${parentName} (${subjectUri})`);
1287
+ else lines.push(` on: ${subjectUri}`);
1288
+ if (attributionUri) {
1289
+ lines.push(` attribution: ${attributionUri} (org.simocracy.history sidecar)`);
1290
+ } else if (attributionWarning) {
1291
+ lines.push(` WARNING: ${attributionWarning}`);
1292
+ lines.push(
1293
+ ` The comment is posted but will appear unattributed until a history sidecar is written.`,
1294
+ );
1295
+ }
1296
+ return {
1297
+ content: [{ type: "text" as const, text: lines.join("\n") }],
1298
+ details: {
1299
+ commentUri: comment.uri,
1300
+ commentRkey: comment.rkey,
1301
+ subjectUri,
1302
+ parentName,
1303
+ parentCollection,
1304
+ simUri: loadedSim.uri,
1305
+ simName: loadedSim.name,
1306
+ attributionUri,
1307
+ attributionWarning,
1308
+ },
1309
+ };
1310
+ },
1311
+ });
1312
+
1313
+ // -------------------------------------------------------------------------
1314
+ // Tool: simocracy_lookup_record
1315
+ //
1316
+ // Look up a sim / proposal / gathering / decision / comment by AT-URI
1317
+ // (exact, fetched from the owner's PDS) or by fuzzy name (fan-out
1318
+ // search across both indexers). Returns the record + comment subtree
1319
+ // with sim attribution joined from org.simocracy.history sidecars.
1320
+ // -------------------------------------------------------------------------
1321
+ pi.registerTool({
1322
+ name: "simocracy_lookup_record",
1323
+ label: "Look up a Simocracy record",
1324
+ description:
1325
+ "Fetch a Simocracy record (sim, proposal, gathering, decision, or comment) by AT-URI or fuzzy name and return its details plus the full comment subtree with sim attribution joined. Use this before `simocracy_post_comment` to find the right `subjectUri`, to inspect what's been said about something, or to read a specific comment thread. Comments authored by sims are flagged with their sim name and AT-URI in the response, so you can tell at a glance which opinions are human and which are sim.",
1326
+ parameters: LookupRecordToolParams,
1327
+ async execute(_id, { query, kind, withComments }) {
1328
+ const { result, alternatives } = await lookupRecord(query, {
1329
+ kind: (kind ?? "auto") as LookupKind,
1330
+ withComments: withComments ?? true,
1331
+ });
1332
+ if (!result) {
1333
+ const tail = alternatives.length
1334
+ ? `\n\nClosest alternatives:\n${alternatives
1335
+ .map((a) => ` - [${a.kind}] ${a.name || "(untitled)"} ${a.uri}`)
1336
+ .join("\n")}`
1337
+ : "";
1338
+ throw new Error(
1339
+ `No record matching "${query}" (kind=${kind ?? "auto"}).${tail}`,
1340
+ );
1341
+ }
1342
+ return {
1343
+ content: [{ type: "text" as const, text: formatLookupResult(result, alternatives) }],
1344
+ details: {
1345
+ kind: result.kind,
1346
+ uri: result.uri,
1347
+ did: result.did,
1348
+ rkey: result.rkey,
1349
+ collection: result.collection,
1350
+ name: result.name,
1351
+ ownerHandle: result.ownerHandle,
1352
+ attribution: result.attribution,
1353
+ parent: result.parent,
1354
+ commentCount: result.comments?.length ?? 0,
1355
+ simAuthoredCommentCount:
1356
+ result.comments?.filter((c) => c.simUri).length ?? 0,
1357
+ alternatives: alternatives.map((a) => ({
1358
+ kind: a.kind,
1359
+ uri: a.uri,
1360
+ name: a.name,
1361
+ })),
1362
+ },
1363
+ };
1364
+ },
1365
+ });
1366
+ }
1367
+
1368
+ // ---------------------------------------------------------------------------
1369
+ // Lookup-result formatter
1370
+ //
1371
+ // Renders a `LookupResult` as a compact, LLM-friendly markdown block.
1372
+ // Calling out sim-authored comments with a 🐾 prefix is the whole
1373
+ // point of the human-vs-sim distinction described in
1374
+ // docs/SIM_AUTHORED_COMMENTS.md — keep it visually distinct from plain
1375
+ // `@handle` lines.
1376
+ // ---------------------------------------------------------------------------
1377
+
1378
+ interface SearchHitForFormat {
1379
+ kind: string;
1380
+ uri: string;
1381
+ name: string;
1382
+ }
1383
+
1384
+ function formatLookupResult(
1385
+ result: LookupResult,
1386
+ alternatives: SearchHitForFormat[] = [],
1387
+ ): string {
1388
+ const lines: string[] = [];
1389
+ const kindLabel = result.kind.toUpperCase();
1390
+ const ownerLabel = result.ownerHandle ? `@${result.ownerHandle}` : result.did;
1391
+ lines.push(`# [${kindLabel}] ${result.name || "(untitled)"}`);
1392
+ lines.push(`- URI: ${result.uri}`);
1393
+ lines.push(`- Owner: ${ownerLabel} (${result.did})`);
1394
+
1395
+ // Kind-specific structured fields (status, treasury, dates, contributors
1396
+ // — the operationally important stuff that doesn't fit in a generic
1397
+ // shortDescription block). Rendered as a compact `Field: value` table.
1398
+ const v = result.value;
1399
+ const facts = collectKindFacts(result.kind, v);
1400
+ if (facts.length > 0) {
1401
+ lines.push("");
1402
+ for (const [k, val] of facts) lines.push(`- ${k}: ${val}`);
1403
+ }
1404
+
1405
+ // Long-form summary (shortDescription / description / context). Capped at
1406
+ // ~25 lines so a verbose gathering context doesn't drown the rest of the
1407
+ // tool output.
1408
+ const longText =
1409
+ (typeof v.shortDescription === "string" && v.shortDescription) ||
1410
+ (typeof v.description === "string" && v.description) ||
1411
+ (typeof v.context === "string" && v.context) ||
1412
+ "";
1413
+ if (longText.trim()) {
1414
+ const trimmed = longText.split("\n").slice(0, 25).join("\n");
1415
+ lines.push("");
1416
+ lines.push("## Summary");
1417
+ lines.push(trimmed);
1418
+ if (longText.split("\n").length > 25) {
1419
+ lines.push("… (truncated)");
1420
+ }
1421
+ }
1422
+
1423
+ // Council sims, suggested templates, etc — references the LLM may want
1424
+ // to drill into via another simocracy_lookup_record call.
1425
+ const refBlocks = collectKindRefs(result.kind, v);
1426
+ for (const block of refBlocks) {
1427
+ lines.push("");
1428
+ lines.push(`## ${block.title}`);
1429
+ for (const ref of block.refs) lines.push(`- ${ref}`);
1430
+ }
1431
+
1432
+ // For comments — surface text + parent + attribution.
1433
+ if (result.kind === "comment") {
1434
+ lines.push("");
1435
+ lines.push("## Comment text");
1436
+ lines.push(((v.text as string) || "").trim() || "(empty)");
1437
+ if (result.attribution) {
1438
+ lines.push("");
1439
+ lines.push(
1440
+ `🐾 Posted on behalf of sim **${result.attribution.simName}** (${result.attribution.simUri})`,
1441
+ );
1442
+ } else {
1443
+ lines.push("");
1444
+ lines.push(`Posted by ${ownerLabel} (no sim attribution).`);
1445
+ }
1446
+ if (result.parent) {
1447
+ lines.push("");
1448
+ lines.push(
1449
+ `## Parent (${result.parent.collection})\n- ${result.parent.name || "(untitled)"}\n- ${result.parent.uri}`,
1450
+ );
1451
+ }
1452
+ }
1453
+
1454
+ // Comment subtree summary.
1455
+ if (result.comments && result.comments.length > 0) {
1456
+ const simCount = result.comments.filter((c) => c.simUri).length;
1457
+ const humanCount = result.comments.length - simCount;
1458
+ lines.push("");
1459
+ lines.push(
1460
+ `## Comments (${result.comments.length} total — ${humanCount} human, ${simCount} sim)`,
1461
+ );
1462
+ // Show up to 25 most recent comments, oldest first within that window.
1463
+ const shown = result.comments.slice(-25);
1464
+ for (const c of shown) {
1465
+ lines.push(formatCommentLine(c));
1466
+ }
1467
+ if (result.comments.length > shown.length) {
1468
+ lines.push(
1469
+ `… ${result.comments.length - shown.length} earlier comment(s) omitted from this preview.`,
1470
+ );
1471
+ }
1472
+ } else if (result.kind !== "comment") {
1473
+ lines.push("");
1474
+ lines.push("_No comments yet._");
1475
+ }
1476
+
1477
+ if (alternatives.length > 0) {
1478
+ lines.push("");
1479
+ lines.push("## Other matches");
1480
+ for (const a of alternatives) {
1481
+ lines.push(`- [${a.kind}] ${a.name || "(untitled)"} ${a.uri}`);
1482
+ }
1483
+ }
1484
+
1485
+ return lines.join("\n");
1486
+ }
1487
+
1488
+ /**
1489
+ * Per-kind structured facts — status, treasury, allocation mechanism, etc.
1490
+ * Returned as `[label, value]` pairs so the formatter can render them as a
1491
+ * compact key/value list. Only fields with actual values are included; absent
1492
+ * or empty fields are omitted entirely so the output stays tight.
1493
+ */
1494
+ function collectKindFacts(
1495
+ kind: string,
1496
+ v: Record<string, unknown>,
1497
+ ): Array<[string, string]> {
1498
+ const out: Array<[string, string]> = [];
1499
+ const str = (k: string): string | undefined => {
1500
+ const x = v[k];
1501
+ return typeof x === "string" && x.trim() ? x : undefined;
1502
+ };
1503
+ const num = (k: string): number | undefined => {
1504
+ const x = v[k];
1505
+ return typeof x === "number" ? x : undefined;
1506
+ };
1507
+ const arr = <T = unknown>(k: string): T[] => {
1508
+ const x = v[k];
1509
+ return Array.isArray(x) ? (x as T[]) : [];
1510
+ };
1511
+ switch (kind) {
1512
+ case "gathering": {
1513
+ // Status · type · mechanism on one row — these are the at-a-glance fields.
1514
+ const statusBits = [
1515
+ str("status"),
1516
+ str("gatheringType"),
1517
+ str("allocationMechanism"),
1518
+ ].filter(Boolean) as string[];
1519
+ if (statusBits.length) out.push(["Status", statusBits.join(" · ")]);
1520
+ const treasury = num("treasuryUsd");
1521
+ if (treasury !== undefined) out.push(["Treasury", `$${treasury.toLocaleString()} USD`]);
1522
+ const dates = str("dates");
1523
+ if (dates) out.push(["Dates", dates]);
1524
+ const location = str("location");
1525
+ if (location) out.push(["Location", location]);
1526
+ const url = str("url");
1527
+ if (url) out.push(["URL", url]);
1528
+ const appRoute = str("appRoute");
1529
+ if (appRoute) out.push(["App route", appRoute]);
1530
+ const collectionUri = str("collectionUri");
1531
+ if (collectionUri) out.push(["Proposal collection", collectionUri]);
1532
+ const scopeBits = [
1533
+ str("simScope") && `sims=${str("simScope")}`,
1534
+ str("proposalScope") && `proposals=${str("proposalScope")}`,
1535
+ str("simSize") && `size=${str("simSize")}`,
1536
+ ].filter(Boolean) as string[];
1537
+ if (scopeBits.length) out.push(["Scope", scopeBits.join(", ")]);
1538
+ const council = arr("councilSims");
1539
+ if (council.length) out.push(["Council sims", `${council.length} — see below`]);
1540
+ break;
1541
+ }
1542
+ case "proposal": {
1543
+ const startDate = str("startDate");
1544
+ const endDate = str("endDate");
1545
+ if (startDate || endDate) {
1546
+ out.push(["Dates", `${startDate || "?"} → ${endDate || "?"}`]);
1547
+ }
1548
+ const ws = v.workScope as Record<string, unknown> | undefined;
1549
+ if (ws && typeof ws === "object") {
1550
+ const scope = ws.scope || ws.expression;
1551
+ if (typeof scope === "string" && scope.trim()) {
1552
+ out.push(["Workscope", scope]);
1553
+ }
1554
+ }
1555
+ const contribs = arr<Record<string, unknown>>("contributors");
1556
+ if (contribs.length) {
1557
+ const names = contribs
1558
+ .map((c) => {
1559
+ const ci = c.contributorIdentity;
1560
+ if (typeof ci === "string") return ci;
1561
+ if (ci && typeof ci === "object" && "uri" in ci) {
1562
+ return (ci as { uri: string }).uri;
1563
+ }
1564
+ return null;
1565
+ })
1566
+ .filter((x): x is string => !!x);
1567
+ out.push([
1568
+ "Contributors",
1569
+ names.length
1570
+ ? `${contribs.length} (${names.slice(0, 3).join(", ")}${names.length > 3 ? "…" : ""})`
1571
+ : `${contribs.length}`,
1572
+ ]);
1573
+ }
1574
+ break;
1575
+ }
1576
+ case "decision": {
1577
+ const mech = str("mechanism");
1578
+ if (mech) out.push(["Mechanism", mech]);
1579
+ const budget = num("budget");
1580
+ if (budget !== undefined) out.push(["Budget", `$${budget.toLocaleString()} USD`]);
1581
+ const outside = num("outsideOptionKept");
1582
+ if (outside !== undefined) out.push(["Outside option kept", `$${outside.toLocaleString()} USD`]);
1583
+ const allocs = arr("allocations");
1584
+ if (allocs.length) out.push(["Allocations", `${allocs.length} proposal(s)`]);
1585
+ const decidedAt = str("decidedAt");
1586
+ if (decidedAt) out.push(["Decided at", decidedAt.slice(0, 19)]);
1587
+ const gatheringUri = str("gatheringUri");
1588
+ if (gatheringUri) out.push(["Gathering", gatheringUri]);
1589
+ break;
1590
+ }
1591
+ case "sim": {
1592
+ const spriteKind = str("spriteKind");
1593
+ if (spriteKind) out.push(["Sprite kind", spriteKind]);
1594
+ const created = str("createdAt");
1595
+ if (created) out.push(["Created", created.slice(0, 10)]);
1596
+ break;
1597
+ }
1598
+ }
1599
+ return out;
1600
+ }
1601
+
1602
+ /**
1603
+ * Per-kind reference blocks — lists of AT-URIs the LLM might want to
1604
+ * `simocracy_lookup_record` next (council sims, allocations breakdown,
1605
+ * etc.). Returned as titled groups so the formatter can render each
1606
+ * block under its own subheading.
1607
+ */
1608
+ function collectKindRefs(
1609
+ kind: string,
1610
+ v: Record<string, unknown>,
1611
+ ): Array<{ title: string; refs: string[] }> {
1612
+ const out: Array<{ title: string; refs: string[] }> = [];
1613
+ const arr = <T = unknown>(k: string): T[] =>
1614
+ Array.isArray(v[k]) ? (v[k] as T[]) : [];
1615
+ if (kind === "gathering") {
1616
+ const council = arr<{ uri?: string }>("councilSims");
1617
+ if (council.length) {
1618
+ out.push({
1619
+ title: "Council sims",
1620
+ refs: council
1621
+ .map((s) => s.uri)
1622
+ .filter((u): u is string => !!u),
1623
+ });
1624
+ }
1625
+ const tmpls = arr<{ uri?: string }>("suggestedInterviewTemplates");
1626
+ if (tmpls.length) {
1627
+ out.push({
1628
+ title: "Suggested interview templates",
1629
+ refs: tmpls.map((t) => t.uri).filter((u): u is string => !!u),
1630
+ });
1631
+ }
1632
+ }
1633
+ if (kind === "decision") {
1634
+ const allocs = arr<Record<string, unknown>>("allocations");
1635
+ if (allocs.length) {
1636
+ out.push({
1637
+ title: "Allocations",
1638
+ refs: allocs.slice(0, 30).map((a) => {
1639
+ const title = (a.proposalTitle as string) || "(untitled)";
1640
+ const amount = a.amount as number | undefined;
1641
+ const requested = a.requested as number | undefined;
1642
+ const uri = (a.proposalUri as string) || "";
1643
+ const amt = amount !== undefined ? `$${amount.toLocaleString()}` : "$?";
1644
+ const req = requested !== undefined ? ` (requested $${requested.toLocaleString()})` : "";
1645
+ return `${amt}${req} — ${title}${uri ? ` ${uri}` : ""}`;
1646
+ }),
1647
+ });
1648
+ }
1649
+ }
1650
+ return out;
1100
1651
  }
1101
1652
 
1653
+ function formatCommentLine(c: ResolvedComment): string {
1654
+ const author = c.simUri
1655
+ ? `🐾 ${c.simName} (sim, written by ${c.authorHandle ? `@${c.authorHandle}` : c.did.slice(0, 16) + "…"})`
1656
+ : c.authorHandle
1657
+ ? `@${c.authorHandle}`
1658
+ : c.did.slice(0, 16) + "…";
1659
+ const when = (c.createdAt || "").slice(0, 19);
1660
+ const head = `- [${when}] ${author}`;
1661
+ const body = c.text.length > 240 ? c.text.slice(0, 237) + "…" : c.text;
1662
+ // Indent body two spaces under the bullet so it stays visually grouped.
1663
+ return `${head}\n ${body.replace(/\n/g, "\n ")}`;
1664
+ }
1102
1665
 
1103
1666
  // ---------------------------------------------------------------------------
1104
1667
  // Slash-command flow