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