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/README.md +49 -201
- package/docs/SIM_AUTHORED_COMMENTS.md +197 -0
- package/package.json +2 -1
- package/src/index.ts +545 -0
- package/src/lookup.ts +537 -0
- package/src/writes.ts +114 -0
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
|