a tool to help your Letta AI agents navigate bluesky
1import { bsky } from "../utils/bsky.ts";
2import type { AutonomyDeclaration } from "@voyager/autonomy-lexicon";
3import { AUTONOMY_DECLARATION_LEXICON } from "@voyager/autonomy-lexicon";
4import { Lexicons } from "@atproto/lexicon";
5import { agentContext } from "./agentContext.ts";
6
7/**
8 * AT Protocol record type that includes the $type property
9 * Includes index signature for compatibility with AT Protocol API
10 */
11type AutonomyDeclarationRecord = AutonomyDeclaration & {
12 $type: "studio.voyager.account.autonomy";
13 [key: string]: unknown;
14};
15
16/**
17 * Autonomy Declaration Lexicon
18 *
19 * The schema is imported from @voyager/autonomy-lexicon package and
20 * is published at voyager.studio for use by all Cloudseeding instances.
21 *
22 * Schema vs. Records:
23 * - The SCHEMA is published once by voyager.studio (domain owner)
24 * - Each agent creates their own RECORD using this schema
25 *
26 * Template users do NOT need to publish this schema - it's already
27 * published and discoverable via DNS resolution. They only need to
28 * create their own autonomy declaration record (done automatically
29 * by submitAutonomyDeclarationRecord below).
30 *
31 * Canonical source: jsr:@voyager/autonomy-lexicon
32 */
33export { AUTONOMY_DECLARATION_LEXICON };
34
35export const createAutonomyDeclarationRecord = async () => {
36 const automationLevel = Deno.env.get("AUTOMATION_LEVEL")?.toLowerCase();
37 const projectDescription = Deno.env.get("PROJECT_DESCRIPTION");
38 const disclosureUrl = Deno.env.get("DISCLOSURE_URL");
39
40 const responsiblePartyType = Deno.env.get("RESPONSIBLE_PARTY_TYPE")
41 ?.toLowerCase();
42 const responsiblePartyName = Deno.env.get("RESPONSIBLE_PARTY_NAME");
43 const responsiblePartyContact = Deno.env.get("RESPONSIBLE_PARTY_CONTACT");
44 const responsiblePartyBsky = Deno.env.get("RESPONSIBLE_PARTY_BSKY");
45
46 const declarationRecord: AutonomyDeclarationRecord = {
47 $type: "studio.voyager.account.autonomy",
48 usesGenerativeAI: true, // Always true for this project
49 automationLevel: (automationLevel === "assisted" ||
50 automationLevel === "collaborative" ||
51 automationLevel === "automated")
52 ? automationLevel
53 : "automated", // Default to automated if not specified or invalid
54 createdAt: new Date().toISOString(),
55 };
56
57 // Add description if provided
58 if (projectDescription?.trim()) {
59 declarationRecord.description = projectDescription.trim();
60 }
61
62 // Add disclosure URL if provided
63 if (disclosureUrl?.trim()) {
64 declarationRecord.disclosureUrl = disclosureUrl.trim();
65 }
66
67 // Add external services from agentContext (already parsed and validated)
68 if (agentContext.externalServices) {
69 declarationRecord.externalServices = agentContext.externalServices;
70 }
71
72 // Build responsible party object if any fields are provided
73 if (
74 responsiblePartyType ||
75 responsiblePartyName ||
76 responsiblePartyContact ||
77 responsiblePartyBsky
78 ) {
79 declarationRecord.responsibleParty = {};
80
81 // Add type if provided and valid
82 if (
83 responsiblePartyType === "person" ||
84 responsiblePartyType === "organization"
85 ) {
86 declarationRecord.responsibleParty.type = responsiblePartyType;
87 }
88
89 // Add name if provided
90 if (responsiblePartyName?.trim()) {
91 declarationRecord.responsibleParty.name = responsiblePartyName.trim();
92 }
93
94 // Add contact if provided
95 if (responsiblePartyContact?.trim()) {
96 declarationRecord.responsibleParty.contact = responsiblePartyContact
97 .trim();
98 }
99
100 // Handle DID or Handle from RESPONSIBLE_PARTY_BSKY
101 if (responsiblePartyBsky?.trim()) {
102 const bskyIdentifier = responsiblePartyBsky.trim();
103
104 // Check if it's a DID (starts with "did:")
105 if (bskyIdentifier.startsWith("did:")) {
106 declarationRecord.responsibleParty.did = bskyIdentifier;
107 } else {
108 // Assume it's a handle and resolve to DID
109 try {
110 const authorData = await bsky.getProfile({ actor: bskyIdentifier });
111 declarationRecord.responsibleParty.did = authorData.data.did;
112 } catch (error) {
113 console.warn(
114 `Failed to resolve DID for identifier ${bskyIdentifier}:`,
115 error,
116 );
117 // Continue without DID rather than failing
118 }
119 }
120 }
121 }
122
123 return declarationRecord;
124};
125
126export const submitAutonomyDeclarationRecord = async () => {
127 const lex = new Lexicons();
128
129 try {
130 lex.add(AUTONOMY_DECLARATION_LEXICON as any);
131 const record = await createAutonomyDeclarationRecord();
132
133 lex.assertValidRecord(
134 "studio.voyager.account.autonomy",
135 record,
136 );
137
138 const repo = bsky.session?.did;
139 if (!repo) {
140 throw new Error("Not logged in - no DID available");
141 }
142
143 // Check if record already exists
144 let exists = false;
145 try {
146 await bsky.com.atproto.repo.getRecord({
147 repo,
148 collection: "studio.voyager.account.autonomy",
149 rkey: "self",
150 });
151 exists = true;
152 console.log("🔹 Existing autonomy declaration found - updating...");
153 } catch (error: any) {
154 // Handle "record not found" errors (status 400 with error: "RecordNotFound")
155 const isNotFound =
156 error?.status === 400 && error?.error === "RecordNotFound" ||
157 error?.status === 404 ||
158 error?.message?.includes("not found") ||
159 error?.message?.includes("Could not locate record");
160
161 if (isNotFound) {
162 console.log(
163 "🔹 No existing autonomy declaration found - creating new...",
164 );
165 } else {
166 // Re-throw if it's not a "not found" error
167 throw error;
168 }
169 }
170
171 // Create or update the record
172 const result = await bsky.com.atproto.repo.putRecord({
173 repo,
174 collection: "studio.voyager.account.autonomy",
175 rkey: "self",
176 record,
177 });
178
179 console.log(
180 `🔹 Autonomy declaration ${exists ? "updated" : "created"} successfully:`,
181 result,
182 );
183 return result;
184 } catch (error) {
185 console.error("Error submitting autonomy declaration record:", error);
186 throw error;
187 }
188};