ompclaw 0.3.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.
@@ -0,0 +1,290 @@
1
+ export interface TransportIdentity {
2
+ readonly transport: string;
3
+ readonly account: string;
4
+ readonly subject: string;
5
+ }
6
+
7
+ export interface Principal {
8
+ readonly id: string;
9
+ readonly roles: readonly string[];
10
+ }
11
+
12
+ export interface ConversationAddress {
13
+ readonly transport: string;
14
+ readonly account: string;
15
+ readonly channel: string;
16
+ readonly thread?: string;
17
+ }
18
+ export interface DeliveryContext {
19
+ readonly principal: Principal;
20
+ readonly origin: ConversationAddress;
21
+ }
22
+
23
+
24
+ export interface MessageAttachment {
25
+ readonly url: string;
26
+ readonly name?: string;
27
+ readonly mediaType?: string;
28
+ }
29
+
30
+ export interface InboundContent {
31
+ readonly text?: string;
32
+ readonly attachments?: readonly MessageAttachment[];
33
+ }
34
+
35
+ /**
36
+ * Transport-provided data. A principal is deliberately absent: it is derived by
37
+ * the gateway from identity before any inbound handler can observe the message.
38
+ */
39
+ export interface InboundEnvelope {
40
+ readonly id: string;
41
+ readonly sentAt: number;
42
+ readonly identity: TransportIdentity;
43
+ readonly address: ConversationAddress;
44
+ readonly content: InboundContent;
45
+ readonly replyTo?: OutboundReceipt;
46
+ readonly edited?: boolean;
47
+ }
48
+
49
+ export interface InboundMessage extends InboundEnvelope {
50
+ readonly principal: Principal;
51
+ }
52
+
53
+ export interface OutboundContent {
54
+ readonly text?: string;
55
+ readonly attachments?: readonly MessageAttachment[];
56
+ readonly replyTo?: OutboundReceipt;
57
+ readonly format?: "text" | "markdown";
58
+ }
59
+
60
+ /** A transport-native message identifier paired with its issuing transport. */
61
+ export interface OutboundReceipt {
62
+ readonly transport: string;
63
+ readonly messageId: string;
64
+ }
65
+
66
+ export interface Reaction {
67
+ readonly emoji: string;
68
+ }
69
+
70
+ export type TransportCapability =
71
+ | "streamingUpdates"
72
+ | "buttons"
73
+ | "multiSelect"
74
+ | "textInput"
75
+ | "attachments"
76
+ | "reactions"
77
+ | "threads";
78
+
79
+ export interface TransportCapabilities {
80
+ readonly streamingUpdates: boolean;
81
+ readonly buttons: boolean;
82
+ readonly multiSelect: boolean;
83
+ readonly textInput: boolean;
84
+ readonly attachments: boolean;
85
+ readonly reactions: boolean;
86
+ readonly threads: boolean;
87
+ readonly maxMessageLength: number;
88
+ }
89
+
90
+ export interface UiOption {
91
+ readonly value: string;
92
+ readonly label: string;
93
+ readonly description?: string;
94
+ }
95
+
96
+ export interface ConfirmUiRequest {
97
+ readonly type: "confirm";
98
+ readonly title: string;
99
+ readonly message: string;
100
+ readonly confirmLabel?: string;
101
+ readonly cancelLabel?: string;
102
+ }
103
+
104
+ export interface SelectUiRequest {
105
+ readonly type: "select";
106
+ readonly title: string;
107
+ readonly options: readonly UiOption[];
108
+ readonly multiSelect?: boolean;
109
+ }
110
+
111
+ export interface InputUiRequest {
112
+ readonly type: "input";
113
+ readonly title: string;
114
+ readonly prompt?: string;
115
+ readonly initialValue?: string;
116
+ readonly placeholder?: string;
117
+ }
118
+
119
+ export interface EditorUiRequest {
120
+ readonly type: "editor";
121
+ readonly title: string;
122
+ readonly initialValue: string;
123
+ readonly language?: string;
124
+ }
125
+
126
+ export interface NotifyUiRequest {
127
+ readonly type: "notify";
128
+ readonly message: string;
129
+ readonly level?: "info" | "success" | "warning" | "error";
130
+ }
131
+
132
+ export interface OpenUrlUiRequest {
133
+ readonly type: "open_url";
134
+ readonly url: string;
135
+ readonly label?: string;
136
+ }
137
+ export interface StatusUiRequest {
138
+ readonly type: "status";
139
+ readonly key: string;
140
+ readonly text?: string;
141
+ }
142
+
143
+ export interface WidgetUiRequest {
144
+ readonly type: "widget";
145
+ readonly key: string;
146
+ readonly lines?: readonly string[];
147
+ readonly placement?: "aboveEditor" | "belowEditor";
148
+ }
149
+
150
+ export interface TitleUiRequest {
151
+ readonly type: "title";
152
+ readonly title: string;
153
+ }
154
+
155
+ export interface EditorTextUiRequest {
156
+ readonly type: "editor_text";
157
+ readonly text: string;
158
+ }
159
+
160
+
161
+ export type UiRequest =
162
+ | ConfirmUiRequest
163
+ | SelectUiRequest
164
+ | InputUiRequest
165
+ | EditorUiRequest
166
+ | NotifyUiRequest
167
+ | OpenUrlUiRequest
168
+ | StatusUiRequest
169
+ | WidgetUiRequest
170
+ | TitleUiRequest
171
+ | EditorTextUiRequest;
172
+
173
+ export interface ConfirmUiResponse {
174
+ readonly type: "confirm";
175
+ readonly confirmed: boolean;
176
+ }
177
+
178
+ export interface SelectUiResponse {
179
+ readonly type: "select";
180
+ readonly selected: readonly string[];
181
+ }
182
+
183
+ export type InputUiResponse =
184
+ | { readonly type: "input"; readonly cancelled: true }
185
+ | { readonly type: "input"; readonly cancelled: false; readonly value: string };
186
+
187
+ export type EditorUiResponse =
188
+ | { readonly type: "editor"; readonly cancelled: true }
189
+ | { readonly type: "editor"; readonly cancelled: false; readonly value: string };
190
+
191
+ export interface NotifyUiResponse {
192
+ readonly type: "notify";
193
+ readonly acknowledged: true;
194
+ }
195
+
196
+ export interface OpenUrlUiResponse {
197
+ readonly type: "open_url";
198
+ readonly opened: boolean;
199
+ }
200
+ export interface StatusUiResponse {
201
+ readonly type: "status";
202
+ readonly acknowledged: true;
203
+ }
204
+
205
+ export interface WidgetUiResponse {
206
+ readonly type: "widget";
207
+ readonly acknowledged: true;
208
+ }
209
+
210
+ export interface TitleUiResponse {
211
+ readonly type: "title";
212
+ readonly acknowledged: true;
213
+ }
214
+
215
+ export interface EditorTextUiResponse {
216
+ readonly type: "editor_text";
217
+ readonly acknowledged: true;
218
+ }
219
+
220
+
221
+ export type UiResponse =
222
+ | ConfirmUiResponse
223
+ | SelectUiResponse
224
+ | InputUiResponse
225
+ | EditorUiResponse
226
+ | NotifyUiResponse
227
+ | OpenUrlUiResponse
228
+ | StatusUiResponse
229
+ | WidgetUiResponse
230
+ | TitleUiResponse
231
+ | EditorTextUiResponse;
232
+
233
+ export type UiResponseFor<Request extends UiRequest> = Extract<UiResponse, { readonly type: Request["type"] }>;
234
+
235
+ export type ReceiveInbound = (envelope: InboundEnvelope, signal?: AbortSignal) => Promise<void>;
236
+
237
+ export type ResolveTransportIdentity = (
238
+ identity: TransportIdentity,
239
+ signal?: AbortSignal,
240
+ ) => Principal | undefined | Promise<Principal | undefined>;
241
+
242
+ export interface TransportStartContext {
243
+ readonly receive: ReceiveInbound;
244
+ readonly resolveIdentity: ResolveTransportIdentity;
245
+ readonly signal?: AbortSignal;
246
+ }
247
+
248
+ export interface TransportAdapter {
249
+ readonly id: string;
250
+ readonly capabilities: TransportCapabilities;
251
+ start(context: TransportStartContext): void | Promise<void>;
252
+ stop(): void | Promise<void>;
253
+ send(
254
+ address: ConversationAddress,
255
+ content: OutboundContent,
256
+ context: DeliveryContext,
257
+ signal?: AbortSignal,
258
+ ): Promise<OutboundReceipt>;
259
+ update?(
260
+ address: ConversationAddress,
261
+ receipt: OutboundReceipt,
262
+ content: OutboundContent,
263
+ context: DeliveryContext,
264
+ signal?: AbortSignal,
265
+ ): Promise<OutboundReceipt>;
266
+ react?(
267
+ address: ConversationAddress,
268
+ receipt: OutboundReceipt,
269
+ reaction: Reaction,
270
+ context: DeliveryContext,
271
+ signal?: AbortSignal,
272
+ ): Promise<void>;
273
+ presentUi?<Request extends UiRequest>(
274
+ address: ConversationAddress,
275
+ request: Request,
276
+ context: DeliveryContext,
277
+ signal?: AbortSignal,
278
+ ): Promise<UiResponseFor<Request>>;
279
+ }
280
+
281
+ export class UnsupportedTransportCapabilityError extends Error {
282
+ readonly name = "UnsupportedTransportCapabilityError";
283
+
284
+ constructor(
285
+ readonly transport: string,
286
+ readonly capability: TransportCapability,
287
+ ) {
288
+ super(`Transport ${transport} does not support ${capability}`);
289
+ }
290
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export const INBOX_MAX_FILE_BYTES = 20 * 1024 * 1024;
5
+ export const INBOX_MAX_TOTAL_BYTES = 250 * 1024 * 1024;
6
+ export const INBOX_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
+
8
+ type InboxFile = { path: string; size: number; mtimeMs: number };
9
+
10
+ export interface PruneInboxOptions {
11
+ now?: number;
12
+ maxTotalBytes?: number;
13
+ retentionMs?: number;
14
+ preserve?: ReadonlySet<string>;
15
+ }
16
+
17
+ /** Remove expired files, then oldest files until the inbox fits its total quota. */
18
+ export async function pruneInbox(dir: string, options: PruneInboxOptions = {}): Promise<{ totalBytes: number; removed: string[] }> {
19
+ const now = options.now ?? Date.now();
20
+ const maxTotalBytes = options.maxTotalBytes ?? INBOX_MAX_TOTAL_BYTES;
21
+ const retentionMs = options.retentionMs ?? INBOX_RETENTION_MS;
22
+ const preserve = new Set([...(options.preserve ?? [])].map((path) => resolve(path)));
23
+ await mkdir(dir, { recursive: true, mode: 0o700 });
24
+
25
+ const files: InboxFile[] = [];
26
+ for (const name of await readdir(dir)) {
27
+ const path = join(dir, name);
28
+ let info;
29
+ try {
30
+ info = await stat(path);
31
+ } catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
33
+ throw error;
34
+ }
35
+ if (info.isFile()) files.push({ path, size: info.size, mtimeMs: info.mtimeMs });
36
+ }
37
+
38
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs || a.path.localeCompare(b.path));
39
+ const removed: string[] = [];
40
+ let totalBytes = files.reduce((total, file) => total + file.size, 0);
41
+
42
+ for (const file of files) {
43
+ if (preserve.has(resolve(file.path))) continue;
44
+ const expired = now - file.mtimeMs > retentionMs;
45
+ if (!expired && totalBytes <= maxTotalBytes) continue;
46
+ try {
47
+ await unlink(file.path);
48
+ totalBytes -= file.size;
49
+ removed.push(file.path);
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
52
+ totalBytes -= file.size;
53
+ }
54
+ }
55
+
56
+ return { totalBytes, removed };
57
+ }
58
+
59
+ /** Persist one bounded attachment with private permissions and enforce inbox quota. */
60
+ export async function storeInboxFile(dir: string, filename: string, bytes: Uint8Array): Promise<string> {
61
+ if (bytes.byteLength > INBOX_MAX_FILE_BYTES) {
62
+ throw new Error(`Telegram attachment is too large (${bytes.byteLength} bytes, max ${INBOX_MAX_FILE_BYTES})`);
63
+ }
64
+ await pruneInbox(dir);
65
+ const path = join(dir, filename);
66
+ await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
67
+ try {
68
+ const result = await pruneInbox(dir, { preserve: new Set([path]) });
69
+ if (result.totalBytes > INBOX_MAX_TOTAL_BYTES) {
70
+ throw new Error(`Telegram inbox quota exceeded (${result.totalBytes} bytes, max ${INBOX_MAX_TOTAL_BYTES})`);
71
+ }
72
+ return path;
73
+ } catch (error) {
74
+ await unlink(path).catch(() => undefined);
75
+ throw error;
76
+ }
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from "./gateway-types";
2
+ export * from "./gateway-core";
3
+ export * from "./gateway-store";
4
+ export * from "./gateway-tools";
5
+ export * from "./gateway-config";
6
+ export * from "./gateway-app";
7
+ export * from "./rpc-protocol";
8
+ export * from "./rpc-client";
9
+ export * from "./rpc-runtime";
10
+ export * from "./rpc-ui";
11
+ export * from "./transports/telegram/adapter";
12
+ export * from "./transports/websocket/adapter";
13
+ export * from "./transports/websocket/protocol";
@@ -0,0 +1,156 @@
1
+ // Markdown → Telegram MarkdownV2 conversion and message chunking.
2
+ //
3
+ // Telegram caps a message at 4096 characters counted in UTF-16 code units,
4
+ // which is exactly what JavaScript's `String.length` returns — so `.length` is
5
+ // the correct measure throughout (a non-BMP emoji counts as 2, matching
6
+ // Telegram). Callers always fall back to plain text when Telegram rejects a
7
+ // MarkdownV2 parse (HTTP 400 "can't parse entities"), so a *lossy* conversion is
8
+ // acceptable here; a thrown exception is not — `mdToMarkdownV2` never throws.
9
+
10
+ /** Telegram's hard per-message character cap (UTF-16 units). */
11
+ export const TELEGRAM_MAX_CHARS = 4096;
12
+ /** Headroom to reserve when a chunk will be MarkdownV2-escaped (escaping grows text). */
13
+ export const MARKDOWN_HEADROOM = 96;
14
+
15
+ /** Escape every MarkdownV2 special character with a backslash. */
16
+ export function escapeMdV2(s: string): string {
17
+ return s.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, "\\$&");
18
+ }
19
+
20
+ /** Inline formatting for a single line: code/links/bold/italic preserved, the rest escaped. */
21
+ function inlineFormat(s: string): string {
22
+ const stash: string[] = [];
23
+ // Protect already-rendered MarkdownV2 fragments behind private-use sentinels so
24
+ // the final escape pass leaves them untouched. 4 call sites, lockstep protocol.
25
+ const put = (rendered: string): string => `\uE000${stash.push(rendered) - 1}\uE001`;
26
+
27
+ let t = s;
28
+ // Inline code — inside a code span only ` and \ are special.
29
+ t = t.replace(/`([^`\n]+)`/g, (_m, code: string) => put("`" + code.replace(/[`\\]/g, "\\$&") + "`"));
30
+ // Links [text](url) — escape text as normal, escape ) and \ in the URL.
31
+ t = t.replace(/\[([^\]\n]*)\]\(([^)\n]+)\)/g, (_m, text: string, url: string) =>
32
+ put("[" + escapeMdV2(text) + "](" + url.replace(/[)\\]/g, "\\$&") + ")"),
33
+ );
34
+ // Bold **x** → *x*
35
+ t = t.replace(/\*\*([^*\n]+)\*\*/g, (_m, inner: string) => put("*" + escapeMdV2(inner) + "*"));
36
+ // Italic _x_ (not inside a word) and *x* → _x_
37
+ t = t.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, (_m, inner: string) => put("_" + escapeMdV2(inner) + "_"));
38
+ t = t.replace(/\*([^*\n]+)\*/g, (_m, inner: string) => put("_" + escapeMdV2(inner) + "_"));
39
+ // Escape everything that remains, then restore the protected fragments.
40
+ t = escapeMdV2(t);
41
+ return t.replace(/\uE000(\d+)\uE001/g, (_m, n: string) => stash[Number(n)] ?? "");
42
+ }
43
+
44
+ /**
45
+ * Convert assistant-style GitHub markdown to Telegram MarkdownV2. Handles fenced
46
+ * code blocks, inline code, bold, italic, links, and ATX headings; escapes the
47
+ * rest. Never throws — any failure falls back to a fully-escaped plain rendering.
48
+ */
49
+ export function mdToMarkdownV2(md: string): string {
50
+ try {
51
+ const lines = md.split("\n");
52
+ const out: string[] = [];
53
+ let inFence = false;
54
+ let fenceLang = "";
55
+ let buf: string[] = [];
56
+ const flushFence = (): void => {
57
+ const lang = fenceLang.replace(/[^a-zA-Z0-9+#_-]/g, "");
58
+ const body = buf.join("\n").replace(/[`\\]/g, "\\$&");
59
+ out.push("```" + lang + "\n" + body + "\n```");
60
+ buf = [];
61
+ fenceLang = "";
62
+ };
63
+
64
+ for (const line of lines) {
65
+ const fence = /^\s*```(.*)$/.exec(line);
66
+ if (fence) {
67
+ if (inFence) {
68
+ flushFence();
69
+ inFence = false;
70
+ } else {
71
+ inFence = true;
72
+ fenceLang = fence[1] ?? "";
73
+ }
74
+ continue;
75
+ }
76
+ if (inFence) {
77
+ buf.push(line);
78
+ continue;
79
+ }
80
+ const heading = /^\s*(#{1,6})\s+(.*\S)\s*$/.exec(line);
81
+ if (heading) {
82
+ out.push("*" + escapeMdV2(heading[2]) + "*");
83
+ continue;
84
+ }
85
+ out.push(inlineFormat(line));
86
+ }
87
+ if (inFence) flushFence(); // unbalanced fence — close it so the send can't break
88
+ return out.join("\n");
89
+ } catch {
90
+ return escapeMdV2(md);
91
+ }
92
+ }
93
+
94
+ /** Base splitter: prefer paragraph, then line, then space breaks past limit/2, else hard cut. */
95
+ function splitToLimit(text: string, limit: number, mode: "length" | "newline"): string[] {
96
+ if (text.length <= limit) return [text];
97
+ const out: string[] = [];
98
+ let rest = text;
99
+ while (rest.length > limit) {
100
+ let cut = limit;
101
+ if (mode === "newline") {
102
+ const para = rest.lastIndexOf("\n\n", limit);
103
+ const line = rest.lastIndexOf("\n", limit);
104
+ const space = rest.lastIndexOf(" ", limit);
105
+ cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit;
106
+ }
107
+ out.push(rest.slice(0, cut));
108
+ rest = rest.slice(cut).replace(/^\n+/, "");
109
+ }
110
+ if (rest) out.push(rest);
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Split text into Telegram-sized chunks. Ports the Claude plugin's chunker and
116
+ * adds fence repair: if a boundary lands inside an open ``` block, the block is
117
+ * closed at the chunk end and reopened at the next chunk start (language is
118
+ * dropped on the reopened half). Empty input yields no chunks.
119
+ */
120
+ export function chunk(text: string, limit: number, mode: "length" | "newline"): string[] {
121
+ if (text.length === 0) return [];
122
+ const raw = splitToLimit(text, Math.max(1, limit), mode);
123
+ const out: string[] = [];
124
+ let carryOpen = false;
125
+ for (let piece of raw) {
126
+ if (carryOpen) piece = "```\n" + piece;
127
+ const fences = (piece.match(/```/g) ?? []).length;
128
+ if (fences % 2 === 1) {
129
+ piece = piece + "\n```";
130
+ carryOpen = true;
131
+ } else {
132
+ carryOpen = false;
133
+ }
134
+ out.push(piece);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** Width reserved for the `(i/n)` label prepended to every part of a split message. */
140
+ export const PART_LABEL_RESERVE = 16;
141
+
142
+ /**
143
+ * Chunk like {@link chunk}, but when the message does not arrive in one piece
144
+ * prepend `(i/n)` to every part so a reader can see the answer continues. The
145
+ * text is re-split against a smaller budget so the label always fits.
146
+ *
147
+ * `priorParts` counts messages of the same answer already delivered (a stream
148
+ * preview committed mid-turn), so the numbering spans the whole answer.
149
+ */
150
+ export function chunkLabeled(text: string, limit: number, mode: "length" | "newline", priorParts = 0): string[] {
151
+ const parts = chunk(text, limit, mode);
152
+ if (parts.length <= 1 && priorParts === 0) return parts;
153
+ const labelled = chunk(text, Math.max(1, limit - PART_LABEL_RESERVE), mode);
154
+ const total = priorParts + labelled.length;
155
+ return labelled.map((part, i) => `(${priorParts + i + 1}/${total})\n${part}`);
156
+ }