pi-simocracy 0.5.1 → 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
@@ -84,6 +94,13 @@ import {
84
94
  type SimMatch,
85
95
  type StyleRecord,
86
96
  } from "./simocracy.ts";
97
+ import {
98
+ bestNameForRecord,
99
+ lookupRecord,
100
+ type LookupKind,
101
+ type LookupResult,
102
+ type ResolvedComment,
103
+ } from "./lookup.ts";
87
104
  import {
88
105
  decodePng,
89
106
  renderRgbaToAnsi,
@@ -101,6 +118,8 @@ import { readAuth } from "./auth/storage.ts";
101
118
  import {
102
119
  assertCanWriteToSim,
103
120
  createAgents,
121
+ createComment,
122
+ createCommentHistory,
104
123
  createStyle,
105
124
  findRkeyForSim,
106
125
  getAuthenticatedAgent,
@@ -605,6 +624,50 @@ const UpdateSimToolParams = Type.Object({
605
624
  ),
606
625
  });
607
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
+
608
671
  export default async function simocracy(pi: ExtensionAPI) {
609
672
  // -------------------------------------------------------------------------
610
673
  // System prompt injection — every turn the loaded sim's persona is appended.
@@ -1115,8 +1178,490 @@ export default async function simocracy(pi: ExtensionAPI) {
1115
1178
  };
1116
1179
  },
1117
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
+ });
1118
1366
  }
1119
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;
1651
+ }
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
+ }
1120
1665
 
1121
1666
  // ---------------------------------------------------------------------------
1122
1667
  // Slash-command flow