@kodelyth/tlon 2026.5.39 → 2026.5.42

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.
Files changed (91) hide show
  1. package/README.md +5 -0
  2. package/api.ts +16 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +4 -0
  5. package/dist/channel-Bvzym9ez.js +236 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/channel.runtime-CDY2BdfM.js +3626 -0
  8. package/dist/doctor-contract-Ip6FcHDH.js +7 -0
  9. package/dist/doctor-contract-api.js +2 -0
  10. package/dist/index.js +18 -0
  11. package/dist/runtime-BmSb9A-q.js +8 -0
  12. package/dist/runtime-api-Dq8wkBC_.js +4 -0
  13. package/dist/runtime-api.js +2 -0
  14. package/dist/setup-api.js +3 -0
  15. package/dist/setup-core-CF3ryHqs.js +387 -0
  16. package/dist/setup-entry.js +11 -0
  17. package/dist/setup-surface-BM5_V_XL.js +74 -0
  18. package/dist/test-api.js +2 -0
  19. package/doctor-contract-api.ts +1 -0
  20. package/index.ts +16 -0
  21. package/klaw.plugin.json +3 -203
  22. package/package.json +4 -4
  23. package/runtime-api.ts +17 -0
  24. package/setup-api.ts +2 -0
  25. package/setup-entry.ts +9 -0
  26. package/src/account-fields.ts +31 -0
  27. package/src/channel.message-adapter.test.ts +145 -0
  28. package/src/channel.runtime.ts +259 -0
  29. package/src/channel.ts +192 -0
  30. package/src/config-schema.ts +54 -0
  31. package/src/core.test.ts +298 -0
  32. package/src/doctor-contract.ts +9 -0
  33. package/src/doctor.test.ts +46 -0
  34. package/src/doctor.ts +10 -0
  35. package/src/logger-runtime.ts +1 -0
  36. package/src/monitor/approval-runtime.ts +363 -0
  37. package/src/monitor/approval.test.ts +33 -0
  38. package/src/monitor/approval.ts +283 -0
  39. package/src/monitor/authorization.ts +30 -0
  40. package/src/monitor/cites.ts +54 -0
  41. package/src/monitor/discovery.ts +68 -0
  42. package/src/monitor/history.ts +226 -0
  43. package/src/monitor/index.ts +1523 -0
  44. package/src/monitor/media.test.ts +80 -0
  45. package/src/monitor/media.ts +156 -0
  46. package/src/monitor/processed-messages.test.ts +58 -0
  47. package/src/monitor/processed-messages.ts +89 -0
  48. package/src/monitor/settings-helpers.test.ts +113 -0
  49. package/src/monitor/settings-helpers.ts +158 -0
  50. package/src/monitor/utils.ts +402 -0
  51. package/src/runtime.ts +9 -0
  52. package/src/security.test.ts +658 -0
  53. package/src/session-route.ts +40 -0
  54. package/src/settings.ts +391 -0
  55. package/src/setup-core.ts +231 -0
  56. package/src/setup-surface.ts +99 -0
  57. package/src/targets.ts +102 -0
  58. package/src/tlon-api.test.ts +572 -0
  59. package/src/tlon-api.ts +389 -0
  60. package/src/types.ts +160 -0
  61. package/src/urbit/auth.ssrf.test.ts +45 -0
  62. package/src/urbit/auth.ts +48 -0
  63. package/src/urbit/base-url.test.ts +48 -0
  64. package/src/urbit/base-url.ts +61 -0
  65. package/src/urbit/channel-ops.test.ts +36 -0
  66. package/src/urbit/channel-ops.ts +149 -0
  67. package/src/urbit/context.ts +50 -0
  68. package/src/urbit/errors.ts +51 -0
  69. package/src/urbit/fetch.ts +38 -0
  70. package/src/urbit/foreigns.ts +49 -0
  71. package/src/urbit/send.test.ts +83 -0
  72. package/src/urbit/send.ts +228 -0
  73. package/src/urbit/sse-client.test.ts +234 -0
  74. package/src/urbit/sse-client.ts +492 -0
  75. package/src/urbit/story.ts +332 -0
  76. package/src/urbit/upload.test.ts +155 -0
  77. package/src/urbit/upload.ts +60 -0
  78. package/test-api.ts +1 -0
  79. package/tsconfig.json +16 -0
  80. package/api.js +0 -7
  81. package/bundled-skills/@tloncorp/tlon-skill/SKILL.md +0 -501
  82. package/bundled-skills/@tloncorp/tlon-skill/bin/tlon.js +0 -7
  83. package/bundled-skills/@tloncorp/tlon-skill/package.json +0 -40
  84. package/bundled-skills/@tloncorp/tlon-skill/scripts/postinstall.js +0 -7
  85. package/channel-plugin-api.js +0 -7
  86. package/doctor-contract-api.js +0 -7
  87. package/index.js +0 -7
  88. package/runtime-api.js +0 -7
  89. package/setup-api.js +0 -7
  90. package/setup-entry.js +0 -7
  91. package/test-api.js +0 -7
@@ -0,0 +1,228 @@
1
+ import { scot, da } from "@urbit/aura";
2
+ import {
3
+ createMessageReceiptFromOutboundResults,
4
+ type MessageReceiptPartKind,
5
+ } from "klaw/plugin-sdk/channel-message";
6
+ import { markdownToStory, createImageBlock, isImageUrl, type Story } from "./story.js";
7
+
8
+ export type TlonPokeApi = {
9
+ poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
10
+ };
11
+
12
+ type SendTextParams = {
13
+ api: TlonPokeApi;
14
+ fromShip: string;
15
+ toShip: string;
16
+ text: string;
17
+ };
18
+
19
+ type SendStoryParams = {
20
+ api: TlonPokeApi;
21
+ fromShip: string;
22
+ toShip: string;
23
+ story: Story;
24
+ kind?: MessageReceiptPartKind;
25
+ };
26
+
27
+ function createTlonSendReceipt(params: {
28
+ messageId: string;
29
+ conversationId: string;
30
+ kind: MessageReceiptPartKind;
31
+ }) {
32
+ return createMessageReceiptFromOutboundResults({
33
+ results: [
34
+ {
35
+ channel: "tlon",
36
+ messageId: params.messageId,
37
+ conversationId: params.conversationId,
38
+ },
39
+ ],
40
+ threadId: params.conversationId,
41
+ kind: params.kind,
42
+ });
43
+ }
44
+
45
+ export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
46
+ const story: Story = markdownToStory(text);
47
+ return sendDmWithStory({ api, fromShip, toShip, story, kind: "text" });
48
+ }
49
+
50
+ export async function sendDmWithStory({
51
+ api,
52
+ fromShip,
53
+ toShip,
54
+ story,
55
+ kind = "unknown",
56
+ }: SendStoryParams) {
57
+ const sentAt = Date.now();
58
+ const idUd = scot("ud", da.fromUnix(sentAt));
59
+ const id = `${fromShip}/${idUd}`;
60
+
61
+ const delta = {
62
+ add: {
63
+ memo: {
64
+ content: story,
65
+ author: fromShip,
66
+ sent: sentAt,
67
+ },
68
+ kind: null,
69
+ time: null,
70
+ },
71
+ };
72
+
73
+ const action = {
74
+ ship: toShip,
75
+ diff: { id, delta },
76
+ };
77
+
78
+ await api.poke({
79
+ app: "chat",
80
+ mark: "chat-dm-action",
81
+ json: action,
82
+ });
83
+
84
+ return {
85
+ channel: "tlon",
86
+ messageId: id,
87
+ receipt: createTlonSendReceipt({ messageId: id, conversationId: toShip, kind }),
88
+ };
89
+ }
90
+
91
+ type SendGroupParams = {
92
+ api: TlonPokeApi;
93
+ fromShip: string;
94
+ hostShip: string;
95
+ channelName: string;
96
+ text: string;
97
+ replyToId?: string | null;
98
+ };
99
+
100
+ type SendGroupStoryParams = {
101
+ api: TlonPokeApi;
102
+ fromShip: string;
103
+ hostShip: string;
104
+ channelName: string;
105
+ story: Story;
106
+ replyToId?: string | null;
107
+ kind?: MessageReceiptPartKind;
108
+ };
109
+
110
+ export async function sendGroupMessage({
111
+ api,
112
+ fromShip,
113
+ hostShip,
114
+ channelName,
115
+ text,
116
+ replyToId,
117
+ }: SendGroupParams) {
118
+ const story: Story = markdownToStory(text);
119
+ return sendGroupMessageWithStory({
120
+ api,
121
+ fromShip,
122
+ hostShip,
123
+ channelName,
124
+ story,
125
+ replyToId,
126
+ kind: "text",
127
+ });
128
+ }
129
+
130
+ export async function sendGroupMessageWithStory({
131
+ api,
132
+ fromShip,
133
+ hostShip,
134
+ channelName,
135
+ story,
136
+ replyToId,
137
+ kind = "unknown",
138
+ }: SendGroupStoryParams) {
139
+ const sentAt = Date.now();
140
+
141
+ // Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
142
+ let formattedReplyId = replyToId;
143
+ if (replyToId && /^\d+$/.test(replyToId)) {
144
+ try {
145
+ // scot('ud', n) formats a number as @ud with dots
146
+ formattedReplyId = scot("ud", BigInt(replyToId));
147
+ } catch {
148
+ // Fall back to raw ID if formatting fails
149
+ }
150
+ }
151
+
152
+ const action = {
153
+ channel: {
154
+ nest: `chat/${hostShip}/${channelName}`,
155
+ action: formattedReplyId
156
+ ? {
157
+ // Thread reply - needs post wrapper around reply action
158
+ // ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
159
+ post: {
160
+ reply: {
161
+ id: formattedReplyId,
162
+ action: {
163
+ add: {
164
+ content: story,
165
+ author: fromShip,
166
+ sent: sentAt,
167
+ },
168
+ },
169
+ },
170
+ },
171
+ }
172
+ : {
173
+ // Regular post
174
+ post: {
175
+ add: {
176
+ content: story,
177
+ author: fromShip,
178
+ sent: sentAt,
179
+ kind: "/chat",
180
+ blob: null,
181
+ meta: null,
182
+ },
183
+ },
184
+ },
185
+ },
186
+ };
187
+
188
+ await api.poke({
189
+ app: "channels",
190
+ mark: "channel-action-1",
191
+ json: action,
192
+ });
193
+
194
+ const messageId = `${fromShip}/${sentAt}`;
195
+ return {
196
+ channel: "tlon",
197
+ messageId,
198
+ receipt: createTlonSendReceipt({
199
+ messageId,
200
+ conversationId: `${hostShip}/${channelName}`,
201
+ kind,
202
+ }),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Build a story with text and optional media (image)
208
+ */
209
+ export function buildMediaStory(text: string | undefined, mediaUrl: string | undefined): Story {
210
+ const story: Story = [];
211
+ const cleanText = text?.trim() ?? "";
212
+ const cleanUrl = mediaUrl?.trim() ?? "";
213
+
214
+ // Add text content if present
215
+ if (cleanText) {
216
+ story.push(...markdownToStory(cleanText));
217
+ }
218
+
219
+ // Add image block if URL looks like an image
220
+ if (cleanUrl && isImageUrl(cleanUrl)) {
221
+ story.push(createImageBlock(cleanUrl, ""));
222
+ } else if (cleanUrl) {
223
+ // For non-image URLs, add as a link
224
+ story.push({ inline: [{ link: { href: cleanUrl, content: cleanUrl } }] });
225
+ }
226
+
227
+ return story.length > 0 ? story : [{ inline: [""] }];
228
+ }
@@ -0,0 +1,234 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { urbitFetch } from "./fetch.js";
3
+ import { UrbitSSEClient } from "./sse-client.js";
4
+
5
+ // Mock urbitFetch to avoid real network calls
6
+ vi.mock("./fetch.js", () => ({
7
+ urbitFetch: vi.fn(),
8
+ }));
9
+
10
+ // Mock channel-ops to avoid real channel operations
11
+ vi.mock("./channel-ops.js", () => ({
12
+ ensureUrbitChannelOpen: vi.fn().mockResolvedValue(undefined),
13
+ pokeUrbitChannel: vi.fn().mockResolvedValue(undefined),
14
+ scryUrbitPath: vi.fn().mockResolvedValue({}),
15
+ }));
16
+
17
+ function requireFirstMockCall(calls: readonly unknown[][], label: string): unknown[] {
18
+ const call = calls.at(0);
19
+ if (!call) {
20
+ throw new Error(`Expected ${label} call`);
21
+ }
22
+ return call;
23
+ }
24
+
25
+ describe("UrbitSSEClient", () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ describe("subscribe", () => {
35
+ it("sends subscriptions added after connect", async () => {
36
+ const mockUrbitFetch = vi.mocked(urbitFetch);
37
+ mockUrbitFetch.mockResolvedValue({
38
+ response: { ok: true, status: 200 } as unknown as Response,
39
+ finalUrl: "https://example.com",
40
+ release: vi.fn().mockResolvedValue(undefined),
41
+ });
42
+
43
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
44
+ // Simulate connected state
45
+ (client as { isConnected: boolean }).isConnected = true;
46
+
47
+ await client.subscribe({
48
+ app: "chat",
49
+ path: "/dm/~zod",
50
+ event: () => {},
51
+ });
52
+
53
+ expect(mockUrbitFetch).toHaveBeenCalledTimes(1);
54
+ const callArgs = requireFirstMockCall(mockUrbitFetch.mock.calls, "urbit fetch")[0] as
55
+ | Parameters<typeof urbitFetch>[0]
56
+ | undefined;
57
+ if (!callArgs) {
58
+ throw new Error("Expected urbit fetch arguments");
59
+ }
60
+ expect(callArgs.path).toContain("/~/channel/");
61
+ expect(callArgs.init?.method).toBe("PUT");
62
+
63
+ const body = JSON.parse(callArgs.init?.body as string);
64
+ expect(body).toHaveLength(1);
65
+ expect(body[0]).toEqual({
66
+ id: 1,
67
+ action: "subscribe",
68
+ ship: "example",
69
+ app: "chat",
70
+ path: "/dm/~zod",
71
+ });
72
+ });
73
+
74
+ it("queues subscriptions before connect", async () => {
75
+ const mockUrbitFetch = vi.mocked(urbitFetch);
76
+
77
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
78
+ // Not connected yet
79
+
80
+ await client.subscribe({
81
+ app: "chat",
82
+ path: "/dm/~zod",
83
+ event: () => {},
84
+ });
85
+
86
+ // Should not call urbitFetch since not connected
87
+ expect(mockUrbitFetch).not.toHaveBeenCalled();
88
+ // But subscription should be queued
89
+ expect(client.subscriptions).toHaveLength(1);
90
+ expect(client.subscriptions[0]).toEqual({
91
+ id: 1,
92
+ action: "subscribe",
93
+ ship: "example",
94
+ app: "chat",
95
+ path: "/dm/~zod",
96
+ });
97
+ });
98
+ });
99
+
100
+ describe("updateCookie", () => {
101
+ it("normalizes cookie when updating", () => {
102
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
103
+
104
+ // Cookie with extra parts that should be stripped
105
+ client.updateCookie("urbauth-~zod=456; Path=/; HttpOnly");
106
+
107
+ expect(client.cookie).toBe("urbauth-~zod=456");
108
+ });
109
+
110
+ it("handles simple cookie values", () => {
111
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
112
+
113
+ client.updateCookie("urbauth-~zod=newvalue");
114
+
115
+ expect(client.cookie).toBe("urbauth-~zod=newvalue");
116
+ });
117
+ });
118
+
119
+ describe("reconnection", () => {
120
+ it("has autoReconnect enabled by default", () => {
121
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
122
+ expect(client.autoReconnect).toBe(true);
123
+ });
124
+
125
+ it("can disable autoReconnect via options", () => {
126
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
127
+ autoReconnect: false,
128
+ });
129
+ expect(client.autoReconnect).toBe(false);
130
+ });
131
+
132
+ it("stores onReconnect callback", () => {
133
+ const onReconnect = vi.fn();
134
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
135
+ onReconnect,
136
+ });
137
+ expect(client.onReconnect).toBe(onReconnect);
138
+ });
139
+
140
+ it("resets reconnect attempts on successful connect", async () => {
141
+ const mockUrbitFetch = vi.mocked(urbitFetch);
142
+
143
+ // Mock a response that returns a readable stream
144
+ const mockStream = new ReadableStream({
145
+ start(controller) {
146
+ controller.close();
147
+ },
148
+ });
149
+
150
+ mockUrbitFetch.mockResolvedValue({
151
+ response: {
152
+ ok: true,
153
+ status: 200,
154
+ body: mockStream,
155
+ } as unknown as Response,
156
+ finalUrl: "https://example.com",
157
+ release: vi.fn().mockResolvedValue(undefined),
158
+ });
159
+
160
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
161
+ autoReconnect: false, // Disable to prevent reconnect loop
162
+ });
163
+ client.reconnectAttempts = 5;
164
+
165
+ await client.connect();
166
+
167
+ expect(client.reconnectAttempts).toBe(0);
168
+ });
169
+ });
170
+
171
+ describe("event acking", () => {
172
+ it("logs malformed SSE JSON with an owned parser error", () => {
173
+ const logger = { error: vi.fn() };
174
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
175
+ logger,
176
+ });
177
+
178
+ client.processEvent("id: 1\ndata: {not json");
179
+
180
+ expect(logger.error).toHaveBeenCalledWith(
181
+ "Error parsing SSE event: Error: Tlon Urbit SSE event was malformed JSON",
182
+ );
183
+ });
184
+
185
+ it("tracks lastHeardEventId and ackThreshold", () => {
186
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
187
+
188
+ // Access private properties for testing
189
+ const lastHeardEventId = (client as unknown as { lastHeardEventId: number }).lastHeardEventId;
190
+ const ackThreshold = (client as unknown as { ackThreshold: number }).ackThreshold;
191
+
192
+ expect(lastHeardEventId).toBe(-1);
193
+ expect(ackThreshold).toBeGreaterThan(0);
194
+ });
195
+ });
196
+
197
+ describe("constructor", () => {
198
+ it("generates unique channel ID", () => {
199
+ const client1 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
200
+ const client2 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
201
+
202
+ expect(client1.channelId).not.toBe(client2.channelId);
203
+ });
204
+
205
+ it("normalizes cookie in constructor", () => {
206
+ const client = new UrbitSSEClient(
207
+ "https://example.com",
208
+ "urbauth-~zod=123; Path=/; HttpOnly",
209
+ );
210
+
211
+ expect(client.cookie).toBe("urbauth-~zod=123");
212
+ });
213
+
214
+ it("sets default reconnection parameters", () => {
215
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
216
+
217
+ expect(client.maxReconnectAttempts).toBe(10);
218
+ expect(client.reconnectDelay).toBe(1000);
219
+ expect(client.maxReconnectDelay).toBe(30000);
220
+ });
221
+
222
+ it("allows overriding reconnection parameters", () => {
223
+ const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
224
+ maxReconnectAttempts: 5,
225
+ reconnectDelay: 500,
226
+ maxReconnectDelay: 10000,
227
+ });
228
+
229
+ expect(client.maxReconnectAttempts).toBe(5);
230
+ expect(client.reconnectDelay).toBe(500);
231
+ expect(client.maxReconnectDelay).toBe(10000);
232
+ });
233
+ });
234
+ });