pi-simocracy 0.5.1 → 0.6.1
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 +51 -201
- package/docs/SIM_AUTHORED_COMMENTS.md +197 -0
- package/docs/SIM_AUTHORED_PROPOSALS.md +198 -0
- package/package.json +2 -1
- package/src/index.ts +882 -11
- package/src/lookup.ts +537 -0
- package/src/writes.ts +237 -0
package/src/index.ts
CHANGED
|
@@ -48,6 +48,23 @@
|
|
|
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_post_proposal` Submit a new funding proposal
|
|
58
|
+
* (`org.hypercerts.claim.activity`) on
|
|
59
|
+
* behalf of the loaded sim, plus an
|
|
60
|
+
* `org.simocracy.history` sidecar with
|
|
61
|
+
* `type: "proposal"`. Same write pattern
|
|
62
|
+
* as `simocracy_post_comment`. Requires
|
|
63
|
+
* /sim login + sim ownership.
|
|
64
|
+
* - `simocracy_lookup_record` Look up a sim / proposal / gathering /
|
|
65
|
+
* decision / comment by AT-URI or fuzzy
|
|
66
|
+
* name and return its details + comment
|
|
67
|
+
* subtree with sim attribution joined.
|
|
51
68
|
*
|
|
52
69
|
* Note on /login: pi itself ships a built-in `/login` for Anthropic OAuth.
|
|
53
70
|
* To avoid the collision (and to make it explicit you're signing into
|
|
@@ -84,6 +101,13 @@ import {
|
|
|
84
101
|
type SimMatch,
|
|
85
102
|
type StyleRecord,
|
|
86
103
|
} from "./simocracy.ts";
|
|
104
|
+
import {
|
|
105
|
+
bestNameForRecord,
|
|
106
|
+
lookupRecord,
|
|
107
|
+
type LookupKind,
|
|
108
|
+
type LookupResult,
|
|
109
|
+
type ResolvedComment,
|
|
110
|
+
} from "./lookup.ts";
|
|
87
111
|
import {
|
|
88
112
|
decodePng,
|
|
89
113
|
renderRgbaToAnsi,
|
|
@@ -101,6 +125,10 @@ import { readAuth } from "./auth/storage.ts";
|
|
|
101
125
|
import {
|
|
102
126
|
assertCanWriteToSim,
|
|
103
127
|
createAgents,
|
|
128
|
+
createComment,
|
|
129
|
+
createCommentHistory,
|
|
130
|
+
createProposal,
|
|
131
|
+
createProposalHistory,
|
|
104
132
|
createStyle,
|
|
105
133
|
findRkeyForSim,
|
|
106
134
|
getAuthenticatedAgent,
|
|
@@ -459,6 +487,26 @@ async function renderSprite(sim: SimMatch): Promise<SpriteRender | null> {
|
|
|
459
487
|
return null;
|
|
460
488
|
}
|
|
461
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Subcommand keywords reserved by the `/sim` dispatcher. The dispatcher
|
|
492
|
+
* routes these BEFORE falling through to `runLoadFlow`, but we also
|
|
493
|
+
* guard the load flow itself against them as defense-in-depth — if a
|
|
494
|
+
* future regression ever leaks one of these into `runLoadFlow`, the
|
|
495
|
+
* user gets a "did you mean…?" hint instead of a misleading
|
|
496
|
+
* "Searching for 'login'…" + indexer-fetch error.
|
|
497
|
+
*/
|
|
498
|
+
const RESERVED_SUBCOMMANDS = new Set([
|
|
499
|
+
"help",
|
|
500
|
+
"login",
|
|
501
|
+
"logout",
|
|
502
|
+
"whoami",
|
|
503
|
+
"my",
|
|
504
|
+
"mine",
|
|
505
|
+
"unload",
|
|
506
|
+
"clear",
|
|
507
|
+
"status",
|
|
508
|
+
]);
|
|
509
|
+
|
|
462
510
|
async function loadSimByName(query: string): Promise<{
|
|
463
511
|
matches: SimMatch[];
|
|
464
512
|
loaded?: LoadedSim;
|
|
@@ -468,7 +516,15 @@ async function loadSimByName(query: string): Promise<{
|
|
|
468
516
|
try {
|
|
469
517
|
matches = await searchSimsByName(query, { maxResults: 8 });
|
|
470
518
|
} catch (err) {
|
|
471
|
-
|
|
519
|
+
const msg = (err as Error).message;
|
|
520
|
+
// Node's "fetch failed" is opaque — the user can't tell whether the
|
|
521
|
+
// indexer is down, their network is down, or DNS is broken. Rewrite
|
|
522
|
+
// it into something actionable.
|
|
523
|
+
const friendly =
|
|
524
|
+
msg === "fetch failed" || msg.includes("fetch failed")
|
|
525
|
+
? "could not reach the Simocracy indexer at simocracy-indexer-production.up.railway.app — check your internet connection"
|
|
526
|
+
: msg;
|
|
527
|
+
return { matches: [], error: `Indexer search failed: ${friendly}` };
|
|
472
528
|
}
|
|
473
529
|
if (matches.length === 0) {
|
|
474
530
|
return { matches: [], error: `No sim found matching "${query}".` };
|
|
@@ -605,6 +661,159 @@ const UpdateSimToolParams = Type.Object({
|
|
|
605
661
|
),
|
|
606
662
|
});
|
|
607
663
|
|
|
664
|
+
const PostCommentToolParams = Type.Object({
|
|
665
|
+
subjectUri: Type.String({
|
|
666
|
+
description:
|
|
667
|
+
"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.",
|
|
668
|
+
minLength: 1,
|
|
669
|
+
}),
|
|
670
|
+
text: Type.String({
|
|
671
|
+
description:
|
|
672
|
+
"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.",
|
|
673
|
+
minLength: 1,
|
|
674
|
+
maxLength: 5000,
|
|
675
|
+
}),
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Default cover image used by simocracy.org's `ProposalFormDialog` when the
|
|
680
|
+
* user doesn't upload anything. We mirror that exactly so a pi-authored
|
|
681
|
+
* proposal renders with the same banner as a webapp-authored one.
|
|
682
|
+
*/
|
|
683
|
+
const DEFAULT_PROPOSAL_BANNER_URI =
|
|
684
|
+
"https://www.simocracy.org/ftc-sf-default.jpeg";
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Mirror of simocracy-v2's `appendBudgetToDescription` — markers verbatim
|
|
688
|
+
* from `lib/budget-items.ts` so the block round-trips through the
|
|
689
|
+
* webapp's `parseDescriptionWithBudget` reader untouched. We only
|
|
690
|
+
* implement the *append* side here; pi-simocracy never parses
|
|
691
|
+
* existing descriptions back out (proposals are create-only).
|
|
692
|
+
*
|
|
693
|
+
* Returns the description unchanged when `items` is empty or contains
|
|
694
|
+
* no valid (non-empty name + positive amount) entries.
|
|
695
|
+
*/
|
|
696
|
+
const BUDGET_HEADER = "━━━ Budget Request ━━━";
|
|
697
|
+
const TOTAL_PREFIX = "━━━ Total: ";
|
|
698
|
+
const TOTAL_SUFFIX = " ━━━";
|
|
699
|
+
|
|
700
|
+
function formatProposalUsd(amount: number): string {
|
|
701
|
+
const hasDecimals = !Number.isInteger(amount);
|
|
702
|
+
return new Intl.NumberFormat("en-US", {
|
|
703
|
+
style: "currency",
|
|
704
|
+
currency: "USD",
|
|
705
|
+
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
706
|
+
maximumFractionDigits: 2,
|
|
707
|
+
}).format(amount);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function appendBudgetToDescription(
|
|
711
|
+
description: string,
|
|
712
|
+
items: Array<{ item: string; amountUsd: number }>,
|
|
713
|
+
): string {
|
|
714
|
+
const valid = items.filter(
|
|
715
|
+
(i) => i.item.trim() !== "" && i.amountUsd > 0 && Number.isFinite(i.amountUsd),
|
|
716
|
+
);
|
|
717
|
+
if (valid.length === 0) return description;
|
|
718
|
+
const total = valid.reduce((sum, i) => sum + i.amountUsd, 0);
|
|
719
|
+
const block = [
|
|
720
|
+
BUDGET_HEADER,
|
|
721
|
+
...valid.map(
|
|
722
|
+
(i) => `• ${i.item.trim()} — ${formatProposalUsd(i.amountUsd)}`,
|
|
723
|
+
),
|
|
724
|
+
`${TOTAL_PREFIX}${formatProposalUsd(total)}${TOTAL_SUFFIX}`,
|
|
725
|
+
].join("\n");
|
|
726
|
+
const base = description.trim();
|
|
727
|
+
return base ? `${base}\n\n${block}` : block;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const PostProposalToolParams = Type.Object({
|
|
731
|
+
title: Type.String({
|
|
732
|
+
description:
|
|
733
|
+
"Proposal title in the sim's voice. Required, max 256 chars. The sim is already in your system prompt — write the title as they'd phrase it.",
|
|
734
|
+
minLength: 1,
|
|
735
|
+
maxLength: 256,
|
|
736
|
+
}),
|
|
737
|
+
shortDescription: Type.String({
|
|
738
|
+
description:
|
|
739
|
+
"One- or two-sentence pitch for the proposal, in the sim's voice. Required, max 300 chars. Shows up in proposal lists on simocracy.org.",
|
|
740
|
+
minLength: 1,
|
|
741
|
+
maxLength: 300,
|
|
742
|
+
}),
|
|
743
|
+
description: Type.Optional(
|
|
744
|
+
Type.String({
|
|
745
|
+
description:
|
|
746
|
+
"Long-form proposal body in the sim's voice. Plain text. Optional — pass when the user has discussed the project in detail. If `budgetItems` is also passed, an itemized budget block is appended automatically.",
|
|
747
|
+
}),
|
|
748
|
+
),
|
|
749
|
+
workScope: Type.Optional(
|
|
750
|
+
Type.String({
|
|
751
|
+
description:
|
|
752
|
+
"Comma-separated tags describing the work scope (e.g. \"urban agriculture, food security\"). Optional. Stored as a bare string the same way simocracy.org's webapp writes it.",
|
|
753
|
+
}),
|
|
754
|
+
),
|
|
755
|
+
contributors: Type.Optional(
|
|
756
|
+
Type.Array(Type.String({ minLength: 1 }), {
|
|
757
|
+
description:
|
|
758
|
+
"DIDs, handles, or freeform names of people credited as contributors. One entry per contributor. Optional.",
|
|
759
|
+
}),
|
|
760
|
+
),
|
|
761
|
+
budgetItems: Type.Optional(
|
|
762
|
+
Type.Array(
|
|
763
|
+
Type.Object({
|
|
764
|
+
item: Type.String({
|
|
765
|
+
minLength: 1,
|
|
766
|
+
description: "What the line-item is funding (e.g. \"Solar panels\").",
|
|
767
|
+
}),
|
|
768
|
+
amountUsd: Type.Number({
|
|
769
|
+
minimum: 0,
|
|
770
|
+
description: "USD amount for this line-item. Must be > 0 to be included.",
|
|
771
|
+
}),
|
|
772
|
+
}),
|
|
773
|
+
{
|
|
774
|
+
description:
|
|
775
|
+
"Itemized budget request. When provided, an `━━━ Budget Request ━━━` block is appended to `description` so it renders the same way simocracy.org's proposal form writes it. Pass when the user discussed a budget; omit when they didn't.",
|
|
776
|
+
},
|
|
777
|
+
),
|
|
778
|
+
),
|
|
779
|
+
imageUri: Type.Optional(
|
|
780
|
+
Type.String({
|
|
781
|
+
description:
|
|
782
|
+
"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
|
+
}),
|
|
784
|
+
),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const LookupRecordToolParams = Type.Object({
|
|
788
|
+
query: Type.String({
|
|
789
|
+
description:
|
|
790
|
+
"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.",
|
|
791
|
+
minLength: 1,
|
|
792
|
+
}),
|
|
793
|
+
kind: Type.Optional(
|
|
794
|
+
Type.Union(
|
|
795
|
+
[
|
|
796
|
+
Type.Literal("auto"),
|
|
797
|
+
Type.Literal("sim"),
|
|
798
|
+
Type.Literal("proposal"),
|
|
799
|
+
Type.Literal("gathering"),
|
|
800
|
+
Type.Literal("decision"),
|
|
801
|
+
Type.Literal("comment"),
|
|
802
|
+
],
|
|
803
|
+
{
|
|
804
|
+
description:
|
|
805
|
+
"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.",
|
|
806
|
+
},
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
withComments: Type.Optional(
|
|
810
|
+
Type.Boolean({
|
|
811
|
+
description:
|
|
812
|
+
"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.",
|
|
813
|
+
}),
|
|
814
|
+
),
|
|
815
|
+
});
|
|
816
|
+
|
|
608
817
|
export default async function simocracy(pi: ExtensionAPI) {
|
|
609
818
|
// -------------------------------------------------------------------------
|
|
610
819
|
// System prompt injection — every turn the loaded sim's persona is appended.
|
|
@@ -746,8 +955,18 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
746
955
|
description:
|
|
747
956
|
"Simocracy: load sims, edit your own sim's constitution/style, sign into ATProto. `/sim help` for the full list.",
|
|
748
957
|
handler: async (args, ctx) => {
|
|
749
|
-
|
|
750
|
-
|
|
958
|
+
// Strip zero-width / format characters that survive `.trim()` —
|
|
959
|
+
// a stray U+200B (ZWSP) glued onto "login" by a paste from a
|
|
960
|
+
// chat client is enough to make `arg === "login"` fail and
|
|
961
|
+
// route the request through `runLoadFlow` as if it were a sim
|
|
962
|
+
// name. We match subcommand keywords against the *lowercased*
|
|
963
|
+
// form, but pass the original-case clean arg through to handlers
|
|
964
|
+
// (sim names are user-facing strings; preserve their case).
|
|
965
|
+
const arg = args
|
|
966
|
+
.trim()
|
|
967
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, "");
|
|
968
|
+
const argLower = arg.toLowerCase();
|
|
969
|
+
if (!arg || argLower === "help" || argLower === "--help") {
|
|
751
970
|
ctx.ui.notify(
|
|
752
971
|
"Sim:\n" +
|
|
753
972
|
" /sim <name> load a sim (e.g. /sim mr meow)\n" +
|
|
@@ -774,27 +993,28 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
774
993
|
}
|
|
775
994
|
// ATProto auth subcommands — must come BEFORE the sim-name
|
|
776
995
|
// fallthrough (`runLoadFlow`) so we don't accidentally treat
|
|
777
|
-
// "login" as a sim name to load from the indexer.
|
|
778
|
-
|
|
996
|
+
// "login" as a sim name to load from the indexer. Match on
|
|
997
|
+
// `argLower` so `/sim Login` and `/sim LOGIN` route the same way.
|
|
998
|
+
if (argLower === "login" || argLower.startsWith("login ") || argLower.startsWith("login\t")) {
|
|
779
999
|
const rest = arg.slice("login".length).trim();
|
|
780
1000
|
await runLogin(ctx, rest);
|
|
781
1001
|
return;
|
|
782
1002
|
}
|
|
783
|
-
if (
|
|
1003
|
+
if (argLower === "logout") {
|
|
784
1004
|
await runLogout(ctx);
|
|
785
1005
|
return;
|
|
786
1006
|
}
|
|
787
|
-
if (
|
|
1007
|
+
if (argLower === "whoami") {
|
|
788
1008
|
await runWhoami(ctx);
|
|
789
1009
|
return;
|
|
790
1010
|
}
|
|
791
|
-
if (
|
|
792
|
-
const headLen =
|
|
1011
|
+
if (argLower === "my" || argLower === "mine" || argLower.startsWith("my ") || argLower.startsWith("my\t") || argLower.startsWith("mine ") || argLower.startsWith("mine\t")) {
|
|
1012
|
+
const headLen = argLower.startsWith("mine") ? 4 : 2;
|
|
793
1013
|
const rest = arg.slice(headLen).trim();
|
|
794
1014
|
await runMySimsCommand(pi, ctx, rest);
|
|
795
1015
|
return;
|
|
796
1016
|
}
|
|
797
|
-
if (
|
|
1017
|
+
if (argLower === "unload" || argLower === "clear") {
|
|
798
1018
|
if (!loadedSim) {
|
|
799
1019
|
ctx.ui.notify("No sim loaded.", "info");
|
|
800
1020
|
return;
|
|
@@ -808,7 +1028,7 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
808
1028
|
ctx.ui.notify(`Unloaded ${name}. Pi will break character on the next reply.`, "info");
|
|
809
1029
|
return;
|
|
810
1030
|
}
|
|
811
|
-
if (
|
|
1031
|
+
if (argLower === "status") {
|
|
812
1032
|
if (!loadedSim) {
|
|
813
1033
|
ctx.ui.notify("No sim loaded. Try `/sim mr meow`.", "info");
|
|
814
1034
|
return;
|
|
@@ -1115,8 +1335,646 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
1115
1335
|
};
|
|
1116
1336
|
},
|
|
1117
1337
|
});
|
|
1338
|
+
|
|
1339
|
+
// -------------------------------------------------------------------------
|
|
1340
|
+
// Tool: simocracy_post_comment
|
|
1341
|
+
//
|
|
1342
|
+
// Writes a comment on behalf of the currently loaded sim. Two records
|
|
1343
|
+
// are written to the user's PDS:
|
|
1344
|
+
//
|
|
1345
|
+
// 1. org.impactindexer.review.comment the comment itself (same wire
|
|
1346
|
+
// shape simocracy.org's webapp writes today, so it threads + renders
|
|
1347
|
+
// identically there).
|
|
1348
|
+
// 2. org.simocracy.history sidecar with type="comment",
|
|
1349
|
+
// simUris=[loadedSim], subjectUri=<comment uri>. Renderers that
|
|
1350
|
+
// understand the join (simocracy.org, when the planned change
|
|
1351
|
+
// lands) display a sim badge; renderers that don't see a regular
|
|
1352
|
+
// user comment — graceful degradation, zero lexicon changes.
|
|
1353
|
+
//
|
|
1354
|
+
// See `docs/SIM_AUTHORED_COMMENTS.md` for the full design.
|
|
1355
|
+
// -------------------------------------------------------------------------
|
|
1356
|
+
pi.registerTool({
|
|
1357
|
+
name: "simocracy_post_comment",
|
|
1358
|
+
label: "Post a comment as the loaded Simocracy sim",
|
|
1359
|
+
description:
|
|
1360
|
+
"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.",
|
|
1361
|
+
parameters: PostCommentToolParams,
|
|
1362
|
+
async execute(_id, { subjectUri, text }) {
|
|
1363
|
+
if (!loadedSim) {
|
|
1364
|
+
throw new Error(
|
|
1365
|
+
"No sim loaded. Call simocracy_load_sim first \u2014 comments are written on behalf of a specific sim.",
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
let auth;
|
|
1369
|
+
try {
|
|
1370
|
+
auth = await assertCanWriteToSim(loadedSim, { action: "post a comment as" });
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
|
|
1373
|
+
throw new Error(err.message);
|
|
1374
|
+
}
|
|
1375
|
+
throw err;
|
|
1376
|
+
}
|
|
1377
|
+
let pdsAgent;
|
|
1378
|
+
try {
|
|
1379
|
+
({ agent: pdsAgent } = await getAuthenticatedAgent());
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
if (err instanceof NotSignedInError) throw new Error(err.message);
|
|
1382
|
+
throw new Error(`ATProto auth failed: ${(err as Error).message}`);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Best-effort fetch of the parent record so we can denormalize its
|
|
1386
|
+
// title onto the history sidecar (drives the timeline UX in
|
|
1387
|
+
// simocracy.org). Failure is non-fatal — the comment goes through
|
|
1388
|
+
// either way, the badge just won't carry a title.
|
|
1389
|
+
let parentName: string | undefined;
|
|
1390
|
+
let parentCollection: string | undefined;
|
|
1391
|
+
try {
|
|
1392
|
+
const parsed = parseAtUri(subjectUri);
|
|
1393
|
+
parentCollection = parsed.collection;
|
|
1394
|
+
const { getRecordFromPds } = await import("./simocracy.ts");
|
|
1395
|
+
const parentValue = await getRecordFromPds<Record<string, unknown>>(
|
|
1396
|
+
parsed.did,
|
|
1397
|
+
parsed.collection,
|
|
1398
|
+
parsed.rkey,
|
|
1399
|
+
);
|
|
1400
|
+
parentName = bestNameForRecord(parsed.collection, parentValue);
|
|
1401
|
+
} catch {
|
|
1402
|
+
/* non-fatal — leave parentName undefined */
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
let comment;
|
|
1406
|
+
try {
|
|
1407
|
+
comment = await createComment({
|
|
1408
|
+
agent: pdsAgent,
|
|
1409
|
+
did: auth.did,
|
|
1410
|
+
subjectUri,
|
|
1411
|
+
text,
|
|
1412
|
+
});
|
|
1413
|
+
} catch (err) {
|
|
1414
|
+
throw new Error(`Comment write failed: ${(err as Error).message}`);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
let attributionUri: string | undefined;
|
|
1418
|
+
let attributionWarning: string | undefined;
|
|
1419
|
+
try {
|
|
1420
|
+
const history = await createCommentHistory({
|
|
1421
|
+
agent: pdsAgent,
|
|
1422
|
+
did: auth.did,
|
|
1423
|
+
commentUri: comment.uri,
|
|
1424
|
+
simUri: loadedSim.uri,
|
|
1425
|
+
simName: loadedSim.name,
|
|
1426
|
+
text,
|
|
1427
|
+
proposalTitle: parentName,
|
|
1428
|
+
parentCollection,
|
|
1429
|
+
parentName,
|
|
1430
|
+
});
|
|
1431
|
+
attributionUri = history.uri;
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
// Don't fail the whole call — the comment is already on the user's
|
|
1434
|
+
// PDS. The sidecar can be re-written later. Surface the warning so
|
|
1435
|
+
// the LLM can decide whether to retry.
|
|
1436
|
+
attributionWarning = `Sim-attribution sidecar failed: ${(err as Error).message}`;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const lines = [
|
|
1440
|
+
`Posted comment as ${loadedSim.name}${loadedSim.handle ? ` (@${loadedSim.handle})` : ""}:`,
|
|
1441
|
+
` comment URI: ${comment.uri}`,
|
|
1442
|
+
];
|
|
1443
|
+
if (parentName) lines.push(` on: ${parentName} (${subjectUri})`);
|
|
1444
|
+
else lines.push(` on: ${subjectUri}`);
|
|
1445
|
+
if (attributionUri) {
|
|
1446
|
+
lines.push(` attribution: ${attributionUri} (org.simocracy.history sidecar)`);
|
|
1447
|
+
} else if (attributionWarning) {
|
|
1448
|
+
lines.push(` WARNING: ${attributionWarning}`);
|
|
1449
|
+
lines.push(
|
|
1450
|
+
` The comment is posted but will appear unattributed until a history sidecar is written.`,
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
return {
|
|
1454
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
1455
|
+
details: {
|
|
1456
|
+
commentUri: comment.uri,
|
|
1457
|
+
commentRkey: comment.rkey,
|
|
1458
|
+
subjectUri,
|
|
1459
|
+
parentName,
|
|
1460
|
+
parentCollection,
|
|
1461
|
+
simUri: loadedSim.uri,
|
|
1462
|
+
simName: loadedSim.name,
|
|
1463
|
+
attributionUri,
|
|
1464
|
+
attributionWarning,
|
|
1465
|
+
},
|
|
1466
|
+
};
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
// -------------------------------------------------------------------------
|
|
1471
|
+
// Tool: simocracy_post_proposal
|
|
1472
|
+
//
|
|
1473
|
+
// Submit a new funding proposal on behalf of the loaded sim. Two
|
|
1474
|
+
// records are written to the user's PDS, mirroring simocracy_post_comment:
|
|
1475
|
+
//
|
|
1476
|
+
// 1. org.hypercerts.claim.activity the proposal itself, in the same
|
|
1477
|
+
// wire shape simocracy.org's ProposalFormDialog writes today, so it
|
|
1478
|
+
// renders identically in the webapp.
|
|
1479
|
+
// 2. org.simocracy.history sidecar with type="proposal",
|
|
1480
|
+
// simUris=[loadedSim], subjectUri=<proposal uri>. Renderers that
|
|
1481
|
+
// understand the join show the sim badge; others see a regular
|
|
1482
|
+
// proposal — graceful degradation, zero lexicon changes.
|
|
1483
|
+
//
|
|
1484
|
+
// See `docs/SIM_AUTHORED_PROPOSALS.md` for the full design.
|
|
1485
|
+
// -------------------------------------------------------------------------
|
|
1486
|
+
pi.registerTool({
|
|
1487
|
+
name: "simocracy_post_proposal",
|
|
1488
|
+
label: "Submit a Simocracy proposal as the loaded sim",
|
|
1489
|
+
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.",
|
|
1491
|
+
parameters: PostProposalToolParams,
|
|
1492
|
+
async execute(
|
|
1493
|
+
_id,
|
|
1494
|
+
{ title, shortDescription, description, workScope, contributors, budgetItems, imageUri },
|
|
1495
|
+
) {
|
|
1496
|
+
if (!loadedSim) {
|
|
1497
|
+
throw new Error(
|
|
1498
|
+
"No sim loaded. Call simocracy_load_sim first — proposals are submitted on behalf of a specific sim.",
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
let auth;
|
|
1502
|
+
try {
|
|
1503
|
+
auth = await assertCanWriteToSim(loadedSim, { action: "post a proposal as" });
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
if (err instanceof NotSignedInError || err instanceof NotSimOwnerError) {
|
|
1506
|
+
throw new Error(err.message);
|
|
1507
|
+
}
|
|
1508
|
+
throw err;
|
|
1509
|
+
}
|
|
1510
|
+
let pdsAgent;
|
|
1511
|
+
try {
|
|
1512
|
+
({ agent: pdsAgent } = await getAuthenticatedAgent());
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
if (err instanceof NotSignedInError) throw new Error(err.message);
|
|
1515
|
+
throw new Error(`ATProto auth failed: ${(err as Error).message}`);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Resolve the cover image — either an LLM-supplied https URL or
|
|
1519
|
+
// the simocracy.org default banner. Reject non-https schemes
|
|
1520
|
+
// (data:, javascript:, file://) defensively even though the only
|
|
1521
|
+
// real downstream consumer is the webapp's <Image> component.
|
|
1522
|
+
let imageRef: { $type: "org.hypercerts.defs#uri"; uri: string };
|
|
1523
|
+
if (imageUri !== undefined) {
|
|
1524
|
+
const trimmed = imageUri.trim();
|
|
1525
|
+
if (!/^https:\/\//i.test(trimmed)) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`imageUri must be an https URL (got "${trimmed}"). Pass an https URL, or omit imageUri to use the default Simocracy banner.`,
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
imageRef = { $type: "org.hypercerts.defs#uri", uri: trimmed };
|
|
1531
|
+
} else {
|
|
1532
|
+
imageRef = { $type: "org.hypercerts.defs#uri", uri: DEFAULT_PROPOSAL_BANNER_URI };
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Append the budget block (if any) to the user-authored description,
|
|
1536
|
+
// exactly the same way simocracy.org's ProposalFormDialog does.
|
|
1537
|
+
const baseDescription = description?.trim() ?? "";
|
|
1538
|
+
const finalDescription = budgetItems
|
|
1539
|
+
? appendBudgetToDescription(baseDescription, budgetItems)
|
|
1540
|
+
: baseDescription;
|
|
1541
|
+
|
|
1542
|
+
// Build contributors in the lexicon shape:
|
|
1543
|
+
// `Array<{ contributorIdentity: string }>`. Drop blank entries.
|
|
1544
|
+
const contributorRecords =
|
|
1545
|
+
contributors && contributors.length > 0
|
|
1546
|
+
? contributors
|
|
1547
|
+
.map((c) => c.trim())
|
|
1548
|
+
.filter((c) => c.length > 0)
|
|
1549
|
+
.map((contributorIdentity) => ({ contributorIdentity }))
|
|
1550
|
+
: undefined;
|
|
1551
|
+
|
|
1552
|
+
let proposal;
|
|
1553
|
+
try {
|
|
1554
|
+
proposal = await createProposal({
|
|
1555
|
+
agent: pdsAgent,
|
|
1556
|
+
did: auth.did,
|
|
1557
|
+
title,
|
|
1558
|
+
shortDescription,
|
|
1559
|
+
description: finalDescription || undefined,
|
|
1560
|
+
workScope: workScope?.trim() || undefined,
|
|
1561
|
+
contributors:
|
|
1562
|
+
contributorRecords && contributorRecords.length > 0
|
|
1563
|
+
? contributorRecords
|
|
1564
|
+
: undefined,
|
|
1565
|
+
image: imageRef,
|
|
1566
|
+
});
|
|
1567
|
+
} catch (err) {
|
|
1568
|
+
throw new Error(`Proposal write failed: ${(err as Error).message}`);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
let sidecarUri: string | undefined;
|
|
1572
|
+
let sidecarWarning: string | undefined;
|
|
1573
|
+
try {
|
|
1574
|
+
const history = await createProposalHistory({
|
|
1575
|
+
agent: pdsAgent,
|
|
1576
|
+
did: auth.did,
|
|
1577
|
+
proposalUri: proposal.uri,
|
|
1578
|
+
proposalTitle: title,
|
|
1579
|
+
simUri: loadedSim.uri,
|
|
1580
|
+
simName: loadedSim.name,
|
|
1581
|
+
content: finalDescription || shortDescription,
|
|
1582
|
+
});
|
|
1583
|
+
sidecarUri = history.uri;
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
// Don't roll back — the proposal is already on the user's PDS.
|
|
1586
|
+
// The sidecar can be re-written later. Surface the warning so the
|
|
1587
|
+
// LLM can decide whether to retry.
|
|
1588
|
+
sidecarWarning = `Sim-attribution sidecar failed: ${(err as Error).message}`;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const lines = [
|
|
1592
|
+
`Submitted proposal as ${loadedSim.name}${loadedSim.handle ? ` (@${loadedSim.handle})` : ""}:`,
|
|
1593
|
+
` title: ${title}`,
|
|
1594
|
+
` proposal URI: ${proposal.uri}`,
|
|
1595
|
+
` image: ${imageRef.uri}`,
|
|
1596
|
+
];
|
|
1597
|
+
if (sidecarUri) {
|
|
1598
|
+
lines.push(` attribution: ${sidecarUri} (org.simocracy.history sidecar)`);
|
|
1599
|
+
} else if (sidecarWarning) {
|
|
1600
|
+
lines.push(` WARNING: ${sidecarWarning}`);
|
|
1601
|
+
lines.push(
|
|
1602
|
+
` The proposal is posted but will appear unattributed until a history sidecar is written.`,
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
1607
|
+
details: {
|
|
1608
|
+
proposalUri: proposal.uri,
|
|
1609
|
+
proposalRkey: proposal.rkey,
|
|
1610
|
+
proposalCid: proposal.cid,
|
|
1611
|
+
title,
|
|
1612
|
+
shortDescription,
|
|
1613
|
+
imageUri: imageRef.uri,
|
|
1614
|
+
workScope: workScope?.trim() || undefined,
|
|
1615
|
+
contributors: contributorRecords,
|
|
1616
|
+
budgetItemCount: budgetItems?.length ?? 0,
|
|
1617
|
+
simUri: loadedSim.uri,
|
|
1618
|
+
simName: loadedSim.name,
|
|
1619
|
+
sidecarUri,
|
|
1620
|
+
sidecarWarning,
|
|
1621
|
+
},
|
|
1622
|
+
};
|
|
1623
|
+
},
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
// -------------------------------------------------------------------------
|
|
1627
|
+
// Tool: simocracy_lookup_record
|
|
1628
|
+
//
|
|
1629
|
+
// Look up a sim / proposal / gathering / decision / comment by AT-URI
|
|
1630
|
+
// (exact, fetched from the owner's PDS) or by fuzzy name (fan-out
|
|
1631
|
+
// search across both indexers). Returns the record + comment subtree
|
|
1632
|
+
// with sim attribution joined from org.simocracy.history sidecars.
|
|
1633
|
+
// -------------------------------------------------------------------------
|
|
1634
|
+
pi.registerTool({
|
|
1635
|
+
name: "simocracy_lookup_record",
|
|
1636
|
+
label: "Look up a Simocracy record",
|
|
1637
|
+
description:
|
|
1638
|
+
"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.",
|
|
1639
|
+
parameters: LookupRecordToolParams,
|
|
1640
|
+
async execute(_id, { query, kind, withComments }) {
|
|
1641
|
+
const { result, alternatives } = await lookupRecord(query, {
|
|
1642
|
+
kind: (kind ?? "auto") as LookupKind,
|
|
1643
|
+
withComments: withComments ?? true,
|
|
1644
|
+
});
|
|
1645
|
+
if (!result) {
|
|
1646
|
+
const tail = alternatives.length
|
|
1647
|
+
? `\n\nClosest alternatives:\n${alternatives
|
|
1648
|
+
.map((a) => ` - [${a.kind}] ${a.name || "(untitled)"} ${a.uri}`)
|
|
1649
|
+
.join("\n")}`
|
|
1650
|
+
: "";
|
|
1651
|
+
throw new Error(
|
|
1652
|
+
`No record matching "${query}" (kind=${kind ?? "auto"}).${tail}`,
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
content: [{ type: "text" as const, text: formatLookupResult(result, alternatives) }],
|
|
1657
|
+
details: {
|
|
1658
|
+
kind: result.kind,
|
|
1659
|
+
uri: result.uri,
|
|
1660
|
+
did: result.did,
|
|
1661
|
+
rkey: result.rkey,
|
|
1662
|
+
collection: result.collection,
|
|
1663
|
+
name: result.name,
|
|
1664
|
+
ownerHandle: result.ownerHandle,
|
|
1665
|
+
attribution: result.attribution,
|
|
1666
|
+
parent: result.parent,
|
|
1667
|
+
commentCount: result.comments?.length ?? 0,
|
|
1668
|
+
simAuthoredCommentCount:
|
|
1669
|
+
result.comments?.filter((c) => c.simUri).length ?? 0,
|
|
1670
|
+
alternatives: alternatives.map((a) => ({
|
|
1671
|
+
kind: a.kind,
|
|
1672
|
+
uri: a.uri,
|
|
1673
|
+
name: a.name,
|
|
1674
|
+
})),
|
|
1675
|
+
},
|
|
1676
|
+
};
|
|
1677
|
+
},
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// ---------------------------------------------------------------------------
|
|
1682
|
+
// Lookup-result formatter
|
|
1683
|
+
//
|
|
1684
|
+
// Renders a `LookupResult` as a compact, LLM-friendly markdown block.
|
|
1685
|
+
// Calling out sim-authored comments with a 🐾 prefix is the whole
|
|
1686
|
+
// point of the human-vs-sim distinction described in
|
|
1687
|
+
// docs/SIM_AUTHORED_COMMENTS.md — keep it visually distinct from plain
|
|
1688
|
+
// `@handle` lines.
|
|
1689
|
+
// ---------------------------------------------------------------------------
|
|
1690
|
+
|
|
1691
|
+
interface SearchHitForFormat {
|
|
1692
|
+
kind: string;
|
|
1693
|
+
uri: string;
|
|
1694
|
+
name: string;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function formatLookupResult(
|
|
1698
|
+
result: LookupResult,
|
|
1699
|
+
alternatives: SearchHitForFormat[] = [],
|
|
1700
|
+
): string {
|
|
1701
|
+
const lines: string[] = [];
|
|
1702
|
+
const kindLabel = result.kind.toUpperCase();
|
|
1703
|
+
const ownerLabel = result.ownerHandle ? `@${result.ownerHandle}` : result.did;
|
|
1704
|
+
lines.push(`# [${kindLabel}] ${result.name || "(untitled)"}`);
|
|
1705
|
+
lines.push(`- URI: ${result.uri}`);
|
|
1706
|
+
lines.push(`- Owner: ${ownerLabel} (${result.did})`);
|
|
1707
|
+
|
|
1708
|
+
// Kind-specific structured fields (status, treasury, dates, contributors
|
|
1709
|
+
// — the operationally important stuff that doesn't fit in a generic
|
|
1710
|
+
// shortDescription block). Rendered as a compact `Field: value` table.
|
|
1711
|
+
const v = result.value;
|
|
1712
|
+
const facts = collectKindFacts(result.kind, v);
|
|
1713
|
+
if (facts.length > 0) {
|
|
1714
|
+
lines.push("");
|
|
1715
|
+
for (const [k, val] of facts) lines.push(`- ${k}: ${val}`);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// Long-form summary (shortDescription / description / context). Capped at
|
|
1719
|
+
// ~25 lines so a verbose gathering context doesn't drown the rest of the
|
|
1720
|
+
// tool output.
|
|
1721
|
+
const longText =
|
|
1722
|
+
(typeof v.shortDescription === "string" && v.shortDescription) ||
|
|
1723
|
+
(typeof v.description === "string" && v.description) ||
|
|
1724
|
+
(typeof v.context === "string" && v.context) ||
|
|
1725
|
+
"";
|
|
1726
|
+
if (longText.trim()) {
|
|
1727
|
+
const trimmed = longText.split("\n").slice(0, 25).join("\n");
|
|
1728
|
+
lines.push("");
|
|
1729
|
+
lines.push("## Summary");
|
|
1730
|
+
lines.push(trimmed);
|
|
1731
|
+
if (longText.split("\n").length > 25) {
|
|
1732
|
+
lines.push("… (truncated)");
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Council sims, suggested templates, etc — references the LLM may want
|
|
1737
|
+
// to drill into via another simocracy_lookup_record call.
|
|
1738
|
+
const refBlocks = collectKindRefs(result.kind, v);
|
|
1739
|
+
for (const block of refBlocks) {
|
|
1740
|
+
lines.push("");
|
|
1741
|
+
lines.push(`## ${block.title}`);
|
|
1742
|
+
for (const ref of block.refs) lines.push(`- ${ref}`);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// For comments — surface text + parent + attribution.
|
|
1746
|
+
if (result.kind === "comment") {
|
|
1747
|
+
lines.push("");
|
|
1748
|
+
lines.push("## Comment text");
|
|
1749
|
+
lines.push(((v.text as string) || "").trim() || "(empty)");
|
|
1750
|
+
if (result.attribution) {
|
|
1751
|
+
lines.push("");
|
|
1752
|
+
lines.push(
|
|
1753
|
+
`🐾 Posted on behalf of sim **${result.attribution.simName}** (${result.attribution.simUri})`,
|
|
1754
|
+
);
|
|
1755
|
+
} else {
|
|
1756
|
+
lines.push("");
|
|
1757
|
+
lines.push(`Posted by ${ownerLabel} (no sim attribution).`);
|
|
1758
|
+
}
|
|
1759
|
+
if (result.parent) {
|
|
1760
|
+
lines.push("");
|
|
1761
|
+
lines.push(
|
|
1762
|
+
`## Parent (${result.parent.collection})\n- ${result.parent.name || "(untitled)"}\n- ${result.parent.uri}`,
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// Comment subtree summary.
|
|
1768
|
+
if (result.comments && result.comments.length > 0) {
|
|
1769
|
+
const simCount = result.comments.filter((c) => c.simUri).length;
|
|
1770
|
+
const humanCount = result.comments.length - simCount;
|
|
1771
|
+
lines.push("");
|
|
1772
|
+
lines.push(
|
|
1773
|
+
`## Comments (${result.comments.length} total — ${humanCount} human, ${simCount} sim)`,
|
|
1774
|
+
);
|
|
1775
|
+
// Show up to 25 most recent comments, oldest first within that window.
|
|
1776
|
+
const shown = result.comments.slice(-25);
|
|
1777
|
+
for (const c of shown) {
|
|
1778
|
+
lines.push(formatCommentLine(c));
|
|
1779
|
+
}
|
|
1780
|
+
if (result.comments.length > shown.length) {
|
|
1781
|
+
lines.push(
|
|
1782
|
+
`… ${result.comments.length - shown.length} earlier comment(s) omitted from this preview.`,
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
} else if (result.kind !== "comment") {
|
|
1786
|
+
lines.push("");
|
|
1787
|
+
lines.push("_No comments yet._");
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (alternatives.length > 0) {
|
|
1791
|
+
lines.push("");
|
|
1792
|
+
lines.push("## Other matches");
|
|
1793
|
+
for (const a of alternatives) {
|
|
1794
|
+
lines.push(`- [${a.kind}] ${a.name || "(untitled)"} ${a.uri}`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
return lines.join("\n");
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Per-kind structured facts — status, treasury, allocation mechanism, etc.
|
|
1803
|
+
* Returned as `[label, value]` pairs so the formatter can render them as a
|
|
1804
|
+
* compact key/value list. Only fields with actual values are included; absent
|
|
1805
|
+
* or empty fields are omitted entirely so the output stays tight.
|
|
1806
|
+
*/
|
|
1807
|
+
function collectKindFacts(
|
|
1808
|
+
kind: string,
|
|
1809
|
+
v: Record<string, unknown>,
|
|
1810
|
+
): Array<[string, string]> {
|
|
1811
|
+
const out: Array<[string, string]> = [];
|
|
1812
|
+
const str = (k: string): string | undefined => {
|
|
1813
|
+
const x = v[k];
|
|
1814
|
+
return typeof x === "string" && x.trim() ? x : undefined;
|
|
1815
|
+
};
|
|
1816
|
+
const num = (k: string): number | undefined => {
|
|
1817
|
+
const x = v[k];
|
|
1818
|
+
return typeof x === "number" ? x : undefined;
|
|
1819
|
+
};
|
|
1820
|
+
const arr = <T = unknown>(k: string): T[] => {
|
|
1821
|
+
const x = v[k];
|
|
1822
|
+
return Array.isArray(x) ? (x as T[]) : [];
|
|
1823
|
+
};
|
|
1824
|
+
switch (kind) {
|
|
1825
|
+
case "gathering": {
|
|
1826
|
+
// Status · type · mechanism on one row — these are the at-a-glance fields.
|
|
1827
|
+
const statusBits = [
|
|
1828
|
+
str("status"),
|
|
1829
|
+
str("gatheringType"),
|
|
1830
|
+
str("allocationMechanism"),
|
|
1831
|
+
].filter(Boolean) as string[];
|
|
1832
|
+
if (statusBits.length) out.push(["Status", statusBits.join(" · ")]);
|
|
1833
|
+
const treasury = num("treasuryUsd");
|
|
1834
|
+
if (treasury !== undefined) out.push(["Treasury", `$${treasury.toLocaleString()} USD`]);
|
|
1835
|
+
const dates = str("dates");
|
|
1836
|
+
if (dates) out.push(["Dates", dates]);
|
|
1837
|
+
const location = str("location");
|
|
1838
|
+
if (location) out.push(["Location", location]);
|
|
1839
|
+
const url = str("url");
|
|
1840
|
+
if (url) out.push(["URL", url]);
|
|
1841
|
+
const appRoute = str("appRoute");
|
|
1842
|
+
if (appRoute) out.push(["App route", appRoute]);
|
|
1843
|
+
const collectionUri = str("collectionUri");
|
|
1844
|
+
if (collectionUri) out.push(["Proposal collection", collectionUri]);
|
|
1845
|
+
const scopeBits = [
|
|
1846
|
+
str("simScope") && `sims=${str("simScope")}`,
|
|
1847
|
+
str("proposalScope") && `proposals=${str("proposalScope")}`,
|
|
1848
|
+
str("simSize") && `size=${str("simSize")}`,
|
|
1849
|
+
].filter(Boolean) as string[];
|
|
1850
|
+
if (scopeBits.length) out.push(["Scope", scopeBits.join(", ")]);
|
|
1851
|
+
const council = arr("councilSims");
|
|
1852
|
+
if (council.length) out.push(["Council sims", `${council.length} — see below`]);
|
|
1853
|
+
break;
|
|
1854
|
+
}
|
|
1855
|
+
case "proposal": {
|
|
1856
|
+
const startDate = str("startDate");
|
|
1857
|
+
const endDate = str("endDate");
|
|
1858
|
+
if (startDate || endDate) {
|
|
1859
|
+
out.push(["Dates", `${startDate || "?"} → ${endDate || "?"}`]);
|
|
1860
|
+
}
|
|
1861
|
+
const ws = v.workScope as Record<string, unknown> | undefined;
|
|
1862
|
+
if (ws && typeof ws === "object") {
|
|
1863
|
+
const scope = ws.scope || ws.expression;
|
|
1864
|
+
if (typeof scope === "string" && scope.trim()) {
|
|
1865
|
+
out.push(["Workscope", scope]);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
const contribs = arr<Record<string, unknown>>("contributors");
|
|
1869
|
+
if (contribs.length) {
|
|
1870
|
+
const names = contribs
|
|
1871
|
+
.map((c) => {
|
|
1872
|
+
const ci = c.contributorIdentity;
|
|
1873
|
+
if (typeof ci === "string") return ci;
|
|
1874
|
+
if (ci && typeof ci === "object" && "uri" in ci) {
|
|
1875
|
+
return (ci as { uri: string }).uri;
|
|
1876
|
+
}
|
|
1877
|
+
return null;
|
|
1878
|
+
})
|
|
1879
|
+
.filter((x): x is string => !!x);
|
|
1880
|
+
out.push([
|
|
1881
|
+
"Contributors",
|
|
1882
|
+
names.length
|
|
1883
|
+
? `${contribs.length} (${names.slice(0, 3).join(", ")}${names.length > 3 ? "…" : ""})`
|
|
1884
|
+
: `${contribs.length}`,
|
|
1885
|
+
]);
|
|
1886
|
+
}
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
case "decision": {
|
|
1890
|
+
const mech = str("mechanism");
|
|
1891
|
+
if (mech) out.push(["Mechanism", mech]);
|
|
1892
|
+
const budget = num("budget");
|
|
1893
|
+
if (budget !== undefined) out.push(["Budget", `$${budget.toLocaleString()} USD`]);
|
|
1894
|
+
const outside = num("outsideOptionKept");
|
|
1895
|
+
if (outside !== undefined) out.push(["Outside option kept", `$${outside.toLocaleString()} USD`]);
|
|
1896
|
+
const allocs = arr("allocations");
|
|
1897
|
+
if (allocs.length) out.push(["Allocations", `${allocs.length} proposal(s)`]);
|
|
1898
|
+
const decidedAt = str("decidedAt");
|
|
1899
|
+
if (decidedAt) out.push(["Decided at", decidedAt.slice(0, 19)]);
|
|
1900
|
+
const gatheringUri = str("gatheringUri");
|
|
1901
|
+
if (gatheringUri) out.push(["Gathering", gatheringUri]);
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
case "sim": {
|
|
1905
|
+
const spriteKind = str("spriteKind");
|
|
1906
|
+
if (spriteKind) out.push(["Sprite kind", spriteKind]);
|
|
1907
|
+
const created = str("createdAt");
|
|
1908
|
+
if (created) out.push(["Created", created.slice(0, 10)]);
|
|
1909
|
+
break;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
return out;
|
|
1118
1913
|
}
|
|
1119
1914
|
|
|
1915
|
+
/**
|
|
1916
|
+
* Per-kind reference blocks — lists of AT-URIs the LLM might want to
|
|
1917
|
+
* `simocracy_lookup_record` next (council sims, allocations breakdown,
|
|
1918
|
+
* etc.). Returned as titled groups so the formatter can render each
|
|
1919
|
+
* block under its own subheading.
|
|
1920
|
+
*/
|
|
1921
|
+
function collectKindRefs(
|
|
1922
|
+
kind: string,
|
|
1923
|
+
v: Record<string, unknown>,
|
|
1924
|
+
): Array<{ title: string; refs: string[] }> {
|
|
1925
|
+
const out: Array<{ title: string; refs: string[] }> = [];
|
|
1926
|
+
const arr = <T = unknown>(k: string): T[] =>
|
|
1927
|
+
Array.isArray(v[k]) ? (v[k] as T[]) : [];
|
|
1928
|
+
if (kind === "gathering") {
|
|
1929
|
+
const council = arr<{ uri?: string }>("councilSims");
|
|
1930
|
+
if (council.length) {
|
|
1931
|
+
out.push({
|
|
1932
|
+
title: "Council sims",
|
|
1933
|
+
refs: council
|
|
1934
|
+
.map((s) => s.uri)
|
|
1935
|
+
.filter((u): u is string => !!u),
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
const tmpls = arr<{ uri?: string }>("suggestedInterviewTemplates");
|
|
1939
|
+
if (tmpls.length) {
|
|
1940
|
+
out.push({
|
|
1941
|
+
title: "Suggested interview templates",
|
|
1942
|
+
refs: tmpls.map((t) => t.uri).filter((u): u is string => !!u),
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
if (kind === "decision") {
|
|
1947
|
+
const allocs = arr<Record<string, unknown>>("allocations");
|
|
1948
|
+
if (allocs.length) {
|
|
1949
|
+
out.push({
|
|
1950
|
+
title: "Allocations",
|
|
1951
|
+
refs: allocs.slice(0, 30).map((a) => {
|
|
1952
|
+
const title = (a.proposalTitle as string) || "(untitled)";
|
|
1953
|
+
const amount = a.amount as number | undefined;
|
|
1954
|
+
const requested = a.requested as number | undefined;
|
|
1955
|
+
const uri = (a.proposalUri as string) || "";
|
|
1956
|
+
const amt = amount !== undefined ? `$${amount.toLocaleString()}` : "$?";
|
|
1957
|
+
const req = requested !== undefined ? ` (requested $${requested.toLocaleString()})` : "";
|
|
1958
|
+
return `${amt}${req} — ${title}${uri ? ` ${uri}` : ""}`;
|
|
1959
|
+
}),
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
return out;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function formatCommentLine(c: ResolvedComment): string {
|
|
1967
|
+
const author = c.simUri
|
|
1968
|
+
? `🐾 ${c.simName} (sim, written by ${c.authorHandle ? `@${c.authorHandle}` : c.did.slice(0, 16) + "…"})`
|
|
1969
|
+
: c.authorHandle
|
|
1970
|
+
? `@${c.authorHandle}`
|
|
1971
|
+
: c.did.slice(0, 16) + "…";
|
|
1972
|
+
const when = (c.createdAt || "").slice(0, 19);
|
|
1973
|
+
const head = `- [${when}] ${author}`;
|
|
1974
|
+
const body = c.text.length > 240 ? c.text.slice(0, 237) + "…" : c.text;
|
|
1975
|
+
// Indent body two spaces under the bullet so it stays visually grouped.
|
|
1976
|
+
return `${head}\n ${body.replace(/\n/g, "\n ")}`;
|
|
1977
|
+
}
|
|
1120
1978
|
|
|
1121
1979
|
// ---------------------------------------------------------------------------
|
|
1122
1980
|
// Slash-command flow
|
|
@@ -1277,6 +2135,19 @@ async function runLoadFlow(
|
|
|
1277
2135
|
ctx: ExtensionCommandContext,
|
|
1278
2136
|
arg: string,
|
|
1279
2137
|
): Promise<void> {
|
|
2138
|
+
// Defense-in-depth: if a reserved subcommand keyword somehow ends up
|
|
2139
|
+
// here (e.g. dispatcher regression, exotic input that bypassed the
|
|
2140
|
+
// case + zero-width normalization in the `/sim` handler), refuse to
|
|
2141
|
+
// search the indexer for it. Otherwise the user sees a misleading
|
|
2142
|
+
// `Searching for "login"…` followed by an indexer-fetch error.
|
|
2143
|
+
const argTrimmed = arg.trim();
|
|
2144
|
+
if (RESERVED_SUBCOMMANDS.has(argTrimmed.toLowerCase())) {
|
|
2145
|
+
ctx.ui.notify(
|
|
2146
|
+
`\`${argTrimmed}\` is a reserved subcommand. Did you mean \`/sim ${argTrimmed.toLowerCase()}\`? Run \`/sim help\` for the full list.`,
|
|
2147
|
+
"error",
|
|
2148
|
+
);
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
1280
2151
|
ctx.ui.notify(`Searching for "${arg}"…`, "info");
|
|
1281
2152
|
let matches: SimMatch[] = [];
|
|
1282
2153
|
if (arg.startsWith("at://")) {
|