@trycua/cua 0.2.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 +121 -0
- package/bin/cua.js +37 -0
- package/browser/cua_sdk-ffi.d.ts +6 -0
- package/browser/cua_sdk-ffi.js +3 -0
- package/browser/cua_sdk-ffi.ts +14 -0
- package/browser/cua_sdk.d.ts +2237 -0
- package/browser/cua_sdk.js +2607 -0
- package/browser/cua_sdk.ts +3644 -0
- package/browser/cyclops_sdk_schema-ffi.d.ts +6 -0
- package/browser/cyclops_sdk_schema-ffi.js +3 -0
- package/browser/cyclops_sdk_schema-ffi.ts +14 -0
- package/browser/cyclops_sdk_schema.d.ts +996 -0
- package/browser/cyclops_sdk_schema.js +2194 -0
- package/browser/cyclops_sdk_schema.ts +2934 -0
- package/browser/fleet_sdk-ffi.d.ts +28 -0
- package/browser/fleet_sdk-ffi.js +3 -0
- package/browser/fleet_sdk-ffi.ts +35 -0
- package/browser/fleet_sdk.d.ts +3056 -0
- package/browser/fleet_sdk.js +5088 -0
- package/browser/fleet_sdk.ts +6830 -0
- package/browser/index.d.ts +2 -0
- package/browser/index.js +18 -0
- package/browser/index.web.ts +34 -0
- package/browser/tsconfig.json +20 -0
- package/browser/wasm-bindgen/index.d.ts +2346 -0
- package/browser/wasm-bindgen/index.js +6642 -0
- package/browser/wasm-bindgen/index_bg.wasm +0 -0
- package/browser/wasm-bindgen/index_bg.wasm.d.ts +1154 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +207 -0
- package/dist/mcp.d.ts +33 -0
- package/dist/mcp.js +41 -0
- package/dist/native/cua_sdk-ffi.d.ts +1038 -0
- package/dist/native/cua_sdk-ffi.js +5095 -0
- package/dist/native/cua_sdk.d.ts +15906 -0
- package/dist/native/cua_sdk.js +27061 -0
- package/dist/native/index.d.ts +7 -0
- package/dist/native/index.js +12 -0
- package/dist/native/node-runtime.d.ts +72 -0
- package/dist/native/node-runtime.js +35 -0
- package/dist/spaces/cursorArt.d.ts +27 -0
- package/dist/spaces/cursorArt.js +59 -0
- package/dist/spaces/errors.d.ts +81 -0
- package/dist/spaces/errors.js +102 -0
- package/dist/spaces/events.d.ts +154 -0
- package/dist/spaces/events.js +182 -0
- package/dist/spaces/groups.d.ts +130 -0
- package/dist/spaces/groups.js +275 -0
- package/dist/spaces/host.d.ts +71 -0
- package/dist/spaces/host.js +154 -0
- package/dist/spaces/index.d.ts +48 -0
- package/dist/spaces/index.js +44 -0
- package/dist/spaces/pip.d.ts +128 -0
- package/dist/spaces/pip.js +250 -0
- package/dist/spaces/presence.d.ts +187 -0
- package/dist/spaces/presence.js +449 -0
- package/dist/spaces/presenceTypes.check.d.ts +12 -0
- package/dist/spaces/presenceTypes.check.js +1 -0
- package/dist/spaces/presenceTypes.d.ts +47 -0
- package/dist/spaces/presenceTypes.js +7 -0
- package/dist/spaces/routines.d.ts +158 -0
- package/dist/spaces/routines.js +339 -0
- package/dist/spaces/thread.d.ts +147 -0
- package/dist/spaces/thread.js +285 -0
- package/dist/spaces/transport/http.d.ts +51 -0
- package/dist/spaces/transport/http.js +113 -0
- package/dist/spaces/transport/index.d.ts +19 -0
- package/dist/spaces/transport/index.js +19 -0
- package/dist/spaces/transport/session.d.ts +120 -0
- package/dist/spaces/transport/session.js +188 -0
- package/dist/spaces/transport/tauri.d.ts +50 -0
- package/dist/spaces/transport/tauri.js +90 -0
- package/dist/spaces/transport/types.d.ts +123 -0
- package/dist/spaces/transport/types.js +59 -0
- package/dist/teleport/controller.d.ts +34 -0
- package/dist/teleport/controller.js +107 -0
- package/dist/teleport/drop.d.ts +62 -0
- package/dist/teleport/drop.js +138 -0
- package/dist/teleport/dropZone.d.ts +59 -0
- package/dist/teleport/dropZone.js +166 -0
- package/dist/teleport/element.d.ts +28 -0
- package/dist/teleport/element.js +340 -0
- package/dist/teleport/index.d.ts +32 -0
- package/dist/teleport/index.js +32 -0
- package/dist/teleport/install.d.ts +31 -0
- package/dist/teleport/install.js +65 -0
- package/dist/teleport/model.d.ts +245 -0
- package/dist/teleport/model.js +334 -0
- package/dist/teleport/windowDrag.d.ts +86 -0
- package/dist/teleport/windowDrag.js +71 -0
- package/package.json +93 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent sends back.
|
|
3
|
+
*
|
|
4
|
+
* The honest situation, as of this release: the agent runtime inside a Space is
|
|
5
|
+
* a terminal. `agent_status` returns the tail of the run's output (the agent
|
|
6
|
+
* runs as a detached, tagged cua-spacesd process). There is no structured
|
|
7
|
+
* result channel yet — no file manifest, no image parts, no approval
|
|
8
|
+
* protocol. The supported agents are launched auto-approved and the sandbox is
|
|
9
|
+
* the safety boundary.
|
|
10
|
+
*
|
|
11
|
+
* So this module does two things and is careful about the difference:
|
|
12
|
+
*
|
|
13
|
+
* 1. It defines the *result model* a chat app with agent coworkers needs — text, files,
|
|
14
|
+
* images, links, approval requests — as a stable discriminated union, so an
|
|
15
|
+
* app can be written against it today and keep working when the runtime
|
|
16
|
+
* starts emitting the real thing.
|
|
17
|
+
* 2. It derives what it can from the terminal transcript, and marks every
|
|
18
|
+
* derived event `derived: true`. A consumer can therefore tell a fact
|
|
19
|
+
* ("the agent emitted this file") from an inference ("a path-shaped string
|
|
20
|
+
* appeared in the scrollback"). Nothing here pretends to be ground truth.
|
|
21
|
+
*
|
|
22
|
+
* When the agent runtime gains a structured channel (see `docs/`), the adapter
|
|
23
|
+
* gains a branch that emits the same events with `derived: false`. The union is
|
|
24
|
+
* the stable contract; the adapter is not.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Turns a growing transcript into events, without re-emitting what it already
|
|
28
|
+
* emitted.
|
|
29
|
+
*
|
|
30
|
+
* The runtime gives us a *tail*, not a stream, so the adapter tracks what it has
|
|
31
|
+
* consumed by content, not by offset: a tail that scrolled past our last known
|
|
32
|
+
* position is detected, and only the portion it can prove is new is emitted.
|
|
33
|
+
* When it cannot prove overlap at all — the tail
|
|
34
|
+
* scrolled entirely past — it emits the whole tail and an `error` event saying
|
|
35
|
+
* output was lost, rather than silently dropping it.
|
|
36
|
+
*/
|
|
37
|
+
export class TranscriptAdapter {
|
|
38
|
+
consumed = '';
|
|
39
|
+
seq = 0;
|
|
40
|
+
/** Feed the newest transcript tail; get back only the newly observed events. */
|
|
41
|
+
ingest(tail, now = () => new Date()) {
|
|
42
|
+
const events = [];
|
|
43
|
+
const fresh = this.diff(tail, events, now);
|
|
44
|
+
if (fresh === '')
|
|
45
|
+
return events;
|
|
46
|
+
this.consumed = tail;
|
|
47
|
+
for (const event of this.extract(fresh, now))
|
|
48
|
+
events.push(event);
|
|
49
|
+
return events;
|
|
50
|
+
}
|
|
51
|
+
/** Reset for a new turn that restarts the session (codex `resume` does this). */
|
|
52
|
+
reset() {
|
|
53
|
+
this.consumed = '';
|
|
54
|
+
}
|
|
55
|
+
diff(tail, events, now) {
|
|
56
|
+
if (this.consumed === '')
|
|
57
|
+
return tail;
|
|
58
|
+
if (tail === this.consumed)
|
|
59
|
+
return '';
|
|
60
|
+
if (tail.startsWith(this.consumed))
|
|
61
|
+
return tail.slice(this.consumed.length);
|
|
62
|
+
// The window scrolled. Find the longest suffix of `consumed` that prefixes
|
|
63
|
+
// `tail`; everything after it is new.
|
|
64
|
+
const max = Math.min(this.consumed.length, tail.length);
|
|
65
|
+
for (let overlap = max; overlap > 0; overlap--) {
|
|
66
|
+
if (tail.startsWith(this.consumed.slice(this.consumed.length - overlap))) {
|
|
67
|
+
return tail.slice(overlap);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
events.push(this.make({
|
|
71
|
+
kind: 'error',
|
|
72
|
+
message: 'transcript scrolled past the polled window; output between the last poll and this one was lost',
|
|
73
|
+
}, now));
|
|
74
|
+
return tail;
|
|
75
|
+
}
|
|
76
|
+
*extract(fresh, now) {
|
|
77
|
+
const text = fresh.trim();
|
|
78
|
+
if (text !== '')
|
|
79
|
+
yield this.make({ kind: 'text', text }, now);
|
|
80
|
+
for (const url of matchAll(fresh, URL_PATTERN)) {
|
|
81
|
+
yield this.make({ kind: 'link', url: trimTrailingPunctuation(url) }, now);
|
|
82
|
+
}
|
|
83
|
+
for (const path of matchAll(fresh, PATH_PATTERN)) {
|
|
84
|
+
const clean = trimTrailingPunctuation(path);
|
|
85
|
+
if (IMAGE_SUFFIX.test(clean)) {
|
|
86
|
+
yield this.make({ kind: 'image', mimeType: mimeForPath(clean), path: clean }, now);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
yield this.make({ kind: 'file', path: clean }, now);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const approval = detectApproval(fresh);
|
|
93
|
+
if (approval) {
|
|
94
|
+
yield this.make({ kind: 'approval_request', prompt: approval.prompt, options: approval.options }, now);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
make(partial, now) {
|
|
98
|
+
return {
|
|
99
|
+
...partial,
|
|
100
|
+
seq: this.seq++,
|
|
101
|
+
observedAt: now().toISOString(),
|
|
102
|
+
derived: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// -- derivation heuristics, deliberately conservative -----------------------
|
|
107
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"')\]]+/g;
|
|
108
|
+
/** Absolute POSIX paths only, with a file extension. A bare `/root` is a
|
|
109
|
+
* directory mention, not a produced artefact, and is not reported. */
|
|
110
|
+
const PATH_PATTERN = /(?<![\w:/])\/(?:[\w.@+-]+\/)+[\w.@+-]+\.[A-Za-z0-9]{1,8}\b/g;
|
|
111
|
+
const IMAGE_SUFFIX = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
|
|
112
|
+
function mimeForPath(path) {
|
|
113
|
+
const suffix = path.toLowerCase().split('.').pop() ?? '';
|
|
114
|
+
if (suffix === 'jpg' || suffix === 'jpeg')
|
|
115
|
+
return 'image/jpeg';
|
|
116
|
+
if (suffix === 'svg')
|
|
117
|
+
return 'image/svg+xml';
|
|
118
|
+
if (suffix === 'gif')
|
|
119
|
+
return 'image/gif';
|
|
120
|
+
if (suffix === 'webp')
|
|
121
|
+
return 'image/webp';
|
|
122
|
+
if (suffix === 'bmp')
|
|
123
|
+
return 'image/bmp';
|
|
124
|
+
return 'image/png';
|
|
125
|
+
}
|
|
126
|
+
function matchAll(text, pattern) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const match of text.matchAll(pattern)) {
|
|
130
|
+
const value = match[0];
|
|
131
|
+
if (!seen.has(value)) {
|
|
132
|
+
seen.add(value);
|
|
133
|
+
out.push(value);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function trimTrailingPunctuation(value) {
|
|
139
|
+
return value.replace(/[).,;:'"\]]+$/, '');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Spot a terminal prompt that is waiting on a human.
|
|
143
|
+
*
|
|
144
|
+
* Only fires on a prompt at the very end of the fresh output — a question in
|
|
145
|
+
* the middle of the scrollback has already been answered. Recognizes the two
|
|
146
|
+
* common shapes: a `[y/N]`-style suffix and a numbered choice list followed by
|
|
147
|
+
* a prompt line.
|
|
148
|
+
*/
|
|
149
|
+
export function detectApproval(fresh) {
|
|
150
|
+
const lines = fresh.split(/\r?\n/);
|
|
151
|
+
let lastIndex = lines.length - 1;
|
|
152
|
+
while (lastIndex >= 0 && (lines[lastIndex] ?? '').trim() === '')
|
|
153
|
+
lastIndex--;
|
|
154
|
+
if (lastIndex < 0)
|
|
155
|
+
return null;
|
|
156
|
+
const tail = (lines[lastIndex] ?? '').trim();
|
|
157
|
+
const yesNo = tail.match(/(.*?)\s*[[(]\s*(y(?:es)?)\s*\/\s*(n(?:o)?)\s*[\])]\s*[?:]?\s*$/i);
|
|
158
|
+
if (yesNo) {
|
|
159
|
+
return {
|
|
160
|
+
prompt: (yesNo[1] ?? '').trim() || tail,
|
|
161
|
+
options: [yesNo[2] ?? 'y', yesNo[3] ?? 'n'],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// A numbered menu: collect contiguous "1) …" / "2. …" lines above the prompt.
|
|
165
|
+
const options = [];
|
|
166
|
+
let cursor = lastIndex;
|
|
167
|
+
const promptLooksLikeQuestion = /[?:]\s*$/.test(tail) || /^[>❯]/.test(tail);
|
|
168
|
+
if (!promptLooksLikeQuestion)
|
|
169
|
+
return null;
|
|
170
|
+
cursor--;
|
|
171
|
+
while (cursor >= 0) {
|
|
172
|
+
const candidate = (lines[cursor] ?? '').trim();
|
|
173
|
+
const numbered = candidate.match(/^(\d+)\s*[).]\s+(.*)$/);
|
|
174
|
+
if (!numbered)
|
|
175
|
+
break;
|
|
176
|
+
options.unshift(numbered[2] ?? '');
|
|
177
|
+
cursor--;
|
|
178
|
+
}
|
|
179
|
+
if (options.length < 2)
|
|
180
|
+
return null;
|
|
181
|
+
return { prompt: tail, options };
|
|
182
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@trycua/cua/spaces/groups`: group chats, one human and two to six Bots in
|
|
3
|
+
* one thread. No native library, no Node imports.
|
|
4
|
+
*
|
|
5
|
+
* A group chat is a fan-out over the members' own agent threads in the one
|
|
6
|
+
* shared Space plus a merged, attributed transcript; it is not a new kind of
|
|
7
|
+
* agent. The harness gives a Bot no way to know who else is in the room, so
|
|
8
|
+
* every message is framed with the room ({@link frameGroupMessage}).
|
|
9
|
+
* Delivery is per member and partial success is normal: a Bot mid-turn
|
|
10
|
+
* refuses, and the refusal lands in the transcript as a marked line.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { GroupChatStore } from "@trycua/cua/spaces/groups"
|
|
14
|
+
*
|
|
15
|
+
* const groups = new GroupChatStore(messenger) // deliver / latestReply / isWorking / displayName
|
|
16
|
+
* const chat = groups.create("Launch", ["ada", "bo"]) // throws outside 2..6
|
|
17
|
+
* await groups.send("Where are we?", chat.id)
|
|
18
|
+
* await groups.collectReplies(chat.id) // poll; never duplicates a line
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/** The product bound: a group of one is a thread; every message fans out to every member. */
|
|
22
|
+
export declare const GROUP_MIN_BOTS = 2;
|
|
23
|
+
export declare const GROUP_MAX_BOTS = 6;
|
|
24
|
+
export type GroupChatErrorCode = "tooFewBots" | "tooManyBots" | "full" | "atFloor" | "alreadyAMember" | "notAMember" | "unknownChat";
|
|
25
|
+
/** A membership rule was broken. The message says which number. */
|
|
26
|
+
export declare class GroupChatError extends Error {
|
|
27
|
+
readonly code: GroupChatErrorCode;
|
|
28
|
+
constructor(code: GroupChatErrorCode, message: string);
|
|
29
|
+
static tooFewBots(have: number): GroupChatError;
|
|
30
|
+
static tooManyBots(have: number): GroupChatError;
|
|
31
|
+
static full(): GroupChatError;
|
|
32
|
+
static atFloor(): GroupChatError;
|
|
33
|
+
static alreadyAMember(id: string): GroupChatError;
|
|
34
|
+
static notAMember(id: string): GroupChatError;
|
|
35
|
+
static unknownChat(id: string): GroupChatError;
|
|
36
|
+
}
|
|
37
|
+
/** Who said a line: the human, a Bot, or the group itself (joins, leaves, refusals). */
|
|
38
|
+
export type GroupSpeaker = {
|
|
39
|
+
kind: "human";
|
|
40
|
+
} | {
|
|
41
|
+
kind: "bot";
|
|
42
|
+
botID: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "system";
|
|
45
|
+
};
|
|
46
|
+
export interface GroupMessage {
|
|
47
|
+
id: string;
|
|
48
|
+
speaker: GroupSpeaker;
|
|
49
|
+
text: string;
|
|
50
|
+
at: string;
|
|
51
|
+
/** This line records a message that did not reach its Bot. */
|
|
52
|
+
undelivered: boolean;
|
|
53
|
+
reaction?: string;
|
|
54
|
+
}
|
|
55
|
+
/** The result of fanning one message out to one member. */
|
|
56
|
+
export interface GroupDelivery {
|
|
57
|
+
botID: string;
|
|
58
|
+
accepted: boolean;
|
|
59
|
+
reason: string;
|
|
60
|
+
}
|
|
61
|
+
/** How a group reaches its Bots. `deliver` never throws: a refusal is a result. */
|
|
62
|
+
export interface GroupMessenger {
|
|
63
|
+
deliver(text: string, botID: string): Promise<GroupDelivery>;
|
|
64
|
+
/** The Bot's most recent utterance, or `undefined`. */
|
|
65
|
+
latestReply(botID: string): Promise<string | undefined>;
|
|
66
|
+
/** Whether the Bot is producing output right now (the typing row). */
|
|
67
|
+
isWorking(botID: string): boolean;
|
|
68
|
+
displayName(botID: string): string;
|
|
69
|
+
}
|
|
70
|
+
/** Whether a Create button should be enabled for `members`. */
|
|
71
|
+
export declare function canCreateGroup(members: readonly string[]): boolean;
|
|
72
|
+
/** A group chat. The 2..6 bound holds for every instance: construction and membership changes throw. */
|
|
73
|
+
export declare class GroupChat {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
title: string;
|
|
76
|
+
readonly createdAt: string;
|
|
77
|
+
messages: GroupMessage[];
|
|
78
|
+
private members;
|
|
79
|
+
constructor(title: string, members: readonly string[], options?: {
|
|
80
|
+
id?: string;
|
|
81
|
+
createdAt?: Date;
|
|
82
|
+
});
|
|
83
|
+
/** Bot ids in join order. The human is implicit. */
|
|
84
|
+
get memberIDs(): readonly string[];
|
|
85
|
+
get isFull(): boolean;
|
|
86
|
+
get isAtFloor(): boolean;
|
|
87
|
+
/** `4 of 6 bots`. */
|
|
88
|
+
get membershipLabel(): string;
|
|
89
|
+
get remainingSeats(): number;
|
|
90
|
+
add(botID: string): void;
|
|
91
|
+
remove(botID: string): void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The framing each member receives: the room, then the human's words on the
|
|
95
|
+
* last line (so an agent that reads the last line still sees them).
|
|
96
|
+
*/
|
|
97
|
+
export declare function frameGroupMessage(text: string, botID: string, chat: GroupChat, name: (botID: string) => string): string;
|
|
98
|
+
/** Group chats: membership, fan-out, and the merged transcript. */
|
|
99
|
+
export declare class GroupChatStore {
|
|
100
|
+
private messenger?;
|
|
101
|
+
chats: GroupChat[];
|
|
102
|
+
/** The last membership complaint, cleared on the next success. */
|
|
103
|
+
lastError: string | undefined;
|
|
104
|
+
/** Bots producing output right now, per chat. */
|
|
105
|
+
readonly working: Map<string, Set<string>>;
|
|
106
|
+
/** chat -> bot -> the last reply already folded in, so a re-poll cannot duplicate it. */
|
|
107
|
+
private readonly consumed;
|
|
108
|
+
private readonly listeners;
|
|
109
|
+
constructor(messenger?: GroupMessenger | undefined);
|
|
110
|
+
attach(messenger: GroupMessenger): void;
|
|
111
|
+
subscribe(fn: () => void): () => void;
|
|
112
|
+
private changed;
|
|
113
|
+
chat(id: string): GroupChat | undefined;
|
|
114
|
+
displayName(botID: string): string;
|
|
115
|
+
private require;
|
|
116
|
+
private line;
|
|
117
|
+
create(title: string, members: readonly string[]): GroupChat;
|
|
118
|
+
delete(id: string): void;
|
|
119
|
+
add(botID: string, chatID: string): void;
|
|
120
|
+
remove(botID: string, chatID: string): void;
|
|
121
|
+
private mutate;
|
|
122
|
+
/** Sends one message to every member; returns each member's delivery. */
|
|
123
|
+
send(text: string, chatID: string): Promise<GroupDelivery[]>;
|
|
124
|
+
/** Folds what members said since the last poll into the transcript, attributed. */
|
|
125
|
+
collectReplies(chatID: string): Promise<GroupMessage[]>;
|
|
126
|
+
/** Attaches a reaction to the last line. */
|
|
127
|
+
react(emoji: string, chatID: string): void;
|
|
128
|
+
refreshWorking(chatID: string): void;
|
|
129
|
+
workingBots(chatID: string): string[];
|
|
130
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@trycua/cua/spaces/groups`: group chats, one human and two to six Bots in
|
|
3
|
+
* one thread. No native library, no Node imports.
|
|
4
|
+
*
|
|
5
|
+
* A group chat is a fan-out over the members' own agent threads in the one
|
|
6
|
+
* shared Space plus a merged, attributed transcript; it is not a new kind of
|
|
7
|
+
* agent. The harness gives a Bot no way to know who else is in the room, so
|
|
8
|
+
* every message is framed with the room ({@link frameGroupMessage}).
|
|
9
|
+
* Delivery is per member and partial success is normal: a Bot mid-turn
|
|
10
|
+
* refuses, and the refusal lands in the transcript as a marked line.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { GroupChatStore } from "@trycua/cua/spaces/groups"
|
|
14
|
+
*
|
|
15
|
+
* const groups = new GroupChatStore(messenger) // deliver / latestReply / isWorking / displayName
|
|
16
|
+
* const chat = groups.create("Launch", ["ada", "bo"]) // throws outside 2..6
|
|
17
|
+
* await groups.send("Where are we?", chat.id)
|
|
18
|
+
* await groups.collectReplies(chat.id) // poll; never duplicates a line
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/** The product bound: a group of one is a thread; every message fans out to every member. */
|
|
22
|
+
export const GROUP_MIN_BOTS = 2;
|
|
23
|
+
export const GROUP_MAX_BOTS = 6;
|
|
24
|
+
/** A membership rule was broken. The message says which number. */
|
|
25
|
+
export class GroupChatError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.name = "GroupChatError";
|
|
31
|
+
}
|
|
32
|
+
static tooFewBots(have) {
|
|
33
|
+
return new GroupChatError("tooFewBots", `A group chat needs at least ${GROUP_MIN_BOTS} bots; ${have} selected.`);
|
|
34
|
+
}
|
|
35
|
+
static tooManyBots(have) {
|
|
36
|
+
return new GroupChatError("tooManyBots", `A group chat holds at most ${GROUP_MAX_BOTS} bots; ${have} selected.`);
|
|
37
|
+
}
|
|
38
|
+
static full() {
|
|
39
|
+
return new GroupChatError("full", `This group is full: ${GROUP_MAX_BOTS} bots is the limit. Remove one to add another.`);
|
|
40
|
+
}
|
|
41
|
+
static atFloor() {
|
|
42
|
+
return new GroupChatError("atFloor", `A group chat needs at least ${GROUP_MIN_BOTS} bots. Add one before removing this one.`);
|
|
43
|
+
}
|
|
44
|
+
static alreadyAMember(id) {
|
|
45
|
+
return new GroupChatError("alreadyAMember", `${id} is already in this group.`);
|
|
46
|
+
}
|
|
47
|
+
static notAMember(id) {
|
|
48
|
+
return new GroupChatError("notAMember", `${id} is not in this group.`);
|
|
49
|
+
}
|
|
50
|
+
static unknownChat(id) {
|
|
51
|
+
return new GroupChatError("unknownChat", `No such group chat: ${id}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function newId() {
|
|
55
|
+
return globalThis.crypto.randomUUID().toUpperCase();
|
|
56
|
+
}
|
|
57
|
+
function dedupe(ids) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const id of ids)
|
|
60
|
+
if (!out.includes(id))
|
|
61
|
+
out.push(id);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Whether a Create button should be enabled for `members`. */
|
|
65
|
+
export function canCreateGroup(members) {
|
|
66
|
+
const n = new Set(members).size;
|
|
67
|
+
return n >= GROUP_MIN_BOTS && n <= GROUP_MAX_BOTS;
|
|
68
|
+
}
|
|
69
|
+
/** A group chat. The 2..6 bound holds for every instance: construction and membership changes throw. */
|
|
70
|
+
export class GroupChat {
|
|
71
|
+
id;
|
|
72
|
+
title;
|
|
73
|
+
createdAt;
|
|
74
|
+
messages = [];
|
|
75
|
+
members;
|
|
76
|
+
constructor(title, members, options = {}) {
|
|
77
|
+
const d = dedupe(members);
|
|
78
|
+
if (d.length < GROUP_MIN_BOTS)
|
|
79
|
+
throw GroupChatError.tooFewBots(d.length);
|
|
80
|
+
if (d.length > GROUP_MAX_BOTS)
|
|
81
|
+
throw GroupChatError.tooManyBots(d.length);
|
|
82
|
+
this.id = options.id ?? newId();
|
|
83
|
+
this.title = title;
|
|
84
|
+
this.createdAt = (options.createdAt ?? new Date()).toISOString();
|
|
85
|
+
this.members = d;
|
|
86
|
+
}
|
|
87
|
+
/** Bot ids in join order. The human is implicit. */
|
|
88
|
+
get memberIDs() {
|
|
89
|
+
return this.members;
|
|
90
|
+
}
|
|
91
|
+
get isFull() {
|
|
92
|
+
return this.members.length >= GROUP_MAX_BOTS;
|
|
93
|
+
}
|
|
94
|
+
get isAtFloor() {
|
|
95
|
+
return this.members.length <= GROUP_MIN_BOTS;
|
|
96
|
+
}
|
|
97
|
+
/** `4 of 6 bots`. */
|
|
98
|
+
get membershipLabel() {
|
|
99
|
+
return `${this.members.length} of ${GROUP_MAX_BOTS} bots`;
|
|
100
|
+
}
|
|
101
|
+
get remainingSeats() {
|
|
102
|
+
return Math.max(0, GROUP_MAX_BOTS - this.members.length);
|
|
103
|
+
}
|
|
104
|
+
add(botID) {
|
|
105
|
+
if (this.members.includes(botID))
|
|
106
|
+
throw GroupChatError.alreadyAMember(botID);
|
|
107
|
+
if (this.isFull)
|
|
108
|
+
throw GroupChatError.full();
|
|
109
|
+
this.members.push(botID);
|
|
110
|
+
}
|
|
111
|
+
remove(botID) {
|
|
112
|
+
if (!this.members.includes(botID))
|
|
113
|
+
throw GroupChatError.notAMember(botID);
|
|
114
|
+
if (this.isAtFloor)
|
|
115
|
+
throw GroupChatError.atFloor();
|
|
116
|
+
this.members = this.members.filter((m) => m !== botID);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The framing each member receives: the room, then the human's words on the
|
|
121
|
+
* last line (so an agent that reads the last line still sees them).
|
|
122
|
+
*/
|
|
123
|
+
export function frameGroupMessage(text, botID, chat, name) {
|
|
124
|
+
const others = chat.memberIDs
|
|
125
|
+
.filter((m) => m !== botID)
|
|
126
|
+
.map(name)
|
|
127
|
+
.join(", ");
|
|
128
|
+
return `[group:${chat.title}] You are in a group chat with the user and ${others}. Answer for your own area only and keep it to a few lines.\n${text}`;
|
|
129
|
+
}
|
|
130
|
+
/** Group chats: membership, fan-out, and the merged transcript. */
|
|
131
|
+
export class GroupChatStore {
|
|
132
|
+
messenger;
|
|
133
|
+
chats = [];
|
|
134
|
+
/** The last membership complaint, cleared on the next success. */
|
|
135
|
+
lastError;
|
|
136
|
+
/** Bots producing output right now, per chat. */
|
|
137
|
+
working = new Map();
|
|
138
|
+
/** chat -> bot -> the last reply already folded in, so a re-poll cannot duplicate it. */
|
|
139
|
+
consumed = new Map();
|
|
140
|
+
listeners = new Set();
|
|
141
|
+
constructor(messenger) {
|
|
142
|
+
this.messenger = messenger;
|
|
143
|
+
}
|
|
144
|
+
attach(messenger) {
|
|
145
|
+
this.messenger = messenger;
|
|
146
|
+
}
|
|
147
|
+
subscribe(fn) {
|
|
148
|
+
this.listeners.add(fn);
|
|
149
|
+
return () => this.listeners.delete(fn);
|
|
150
|
+
}
|
|
151
|
+
changed() {
|
|
152
|
+
for (const l of this.listeners)
|
|
153
|
+
l();
|
|
154
|
+
}
|
|
155
|
+
chat(id) {
|
|
156
|
+
return this.chats.find((c) => c.id === id);
|
|
157
|
+
}
|
|
158
|
+
displayName(botID) {
|
|
159
|
+
return this.messenger?.displayName(botID) ?? botID;
|
|
160
|
+
}
|
|
161
|
+
require(id) {
|
|
162
|
+
const c = this.chat(id);
|
|
163
|
+
if (!c)
|
|
164
|
+
throw GroupChatError.unknownChat(id);
|
|
165
|
+
return c;
|
|
166
|
+
}
|
|
167
|
+
line(chat, speaker, text, undelivered = false) {
|
|
168
|
+
chat.messages.push({ id: newId(), speaker, text, at: new Date().toISOString(), undelivered });
|
|
169
|
+
}
|
|
170
|
+
create(title, members) {
|
|
171
|
+
const chat = new GroupChat(title, members);
|
|
172
|
+
this.chats.push(chat);
|
|
173
|
+
this.lastError = undefined;
|
|
174
|
+
this.changed();
|
|
175
|
+
return chat;
|
|
176
|
+
}
|
|
177
|
+
delete(id) {
|
|
178
|
+
this.chats = this.chats.filter((c) => c.id !== id);
|
|
179
|
+
this.consumed.delete(id);
|
|
180
|
+
this.working.delete(id);
|
|
181
|
+
this.changed();
|
|
182
|
+
}
|
|
183
|
+
add(botID, chatID) {
|
|
184
|
+
this.mutate(chatID, (c) => c.add(botID), (c) => `${this.displayName(botID)} joined, ${c.membershipLabel}.`);
|
|
185
|
+
}
|
|
186
|
+
remove(botID, chatID) {
|
|
187
|
+
this.mutate(chatID, (c) => c.remove(botID), (c) => `${this.displayName(botID)} left, ${c.membershipLabel}.`);
|
|
188
|
+
}
|
|
189
|
+
mutate(chatID, op, announce) {
|
|
190
|
+
const chat = this.require(chatID);
|
|
191
|
+
try {
|
|
192
|
+
op(chat);
|
|
193
|
+
this.line(chat, { kind: "system" }, announce(chat));
|
|
194
|
+
this.lastError = undefined;
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
const text = e instanceof Error ? e.message : String(e);
|
|
198
|
+
this.lastError = text;
|
|
199
|
+
this.line(chat, { kind: "system" }, text, true);
|
|
200
|
+
this.changed();
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
this.changed();
|
|
204
|
+
}
|
|
205
|
+
/** Sends one message to every member; returns each member's delivery. */
|
|
206
|
+
async send(text, chatID) {
|
|
207
|
+
const chat = this.chat(chatID);
|
|
208
|
+
if (!chat)
|
|
209
|
+
return [];
|
|
210
|
+
this.line(chat, { kind: "human" }, text);
|
|
211
|
+
this.changed();
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const botID of chat.memberIDs) {
|
|
214
|
+
const framed = frameGroupMessage(text, botID, chat, (id) => this.displayName(id));
|
|
215
|
+
let d;
|
|
216
|
+
if (!this.messenger)
|
|
217
|
+
d = { botID, accepted: false, reason: "not connected to a Space" };
|
|
218
|
+
else {
|
|
219
|
+
try {
|
|
220
|
+
d = await this.messenger.deliver(framed, botID);
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
d = { botID, accepted: false, reason: e instanceof Error ? e.message : String(e) };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
out.push(d);
|
|
227
|
+
if (!d.accepted)
|
|
228
|
+
this.line(chat, { kind: "bot", botID }, `Did not receive that message: ${d.reason}`, true);
|
|
229
|
+
}
|
|
230
|
+
this.refreshWorking(chatID);
|
|
231
|
+
this.changed();
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/** Folds what members said since the last poll into the transcript, attributed. */
|
|
235
|
+
async collectReplies(chatID) {
|
|
236
|
+
const chat = this.chat(chatID);
|
|
237
|
+
if (!chat || !this.messenger)
|
|
238
|
+
return [];
|
|
239
|
+
const added = [];
|
|
240
|
+
for (const botID of chat.memberIDs) {
|
|
241
|
+
const reply = (await this.messenger.latestReply(botID))?.trim();
|
|
242
|
+
if (!reply)
|
|
243
|
+
continue;
|
|
244
|
+
const seen = this.consumed.get(chatID) ?? new Map();
|
|
245
|
+
if (seen.get(botID) === reply)
|
|
246
|
+
continue;
|
|
247
|
+
seen.set(botID, reply);
|
|
248
|
+
this.consumed.set(chatID, seen);
|
|
249
|
+
this.line(chat, { kind: "bot", botID }, reply);
|
|
250
|
+
added.push(chat.messages[chat.messages.length - 1]);
|
|
251
|
+
}
|
|
252
|
+
this.refreshWorking(chatID);
|
|
253
|
+
if (added.length)
|
|
254
|
+
this.changed();
|
|
255
|
+
return added;
|
|
256
|
+
}
|
|
257
|
+
/** Attaches a reaction to the last line. */
|
|
258
|
+
react(emoji, chatID) {
|
|
259
|
+
const last = this.chat(chatID)?.messages.at(-1);
|
|
260
|
+
if (!last)
|
|
261
|
+
return;
|
|
262
|
+
last.reaction = emoji;
|
|
263
|
+
this.changed();
|
|
264
|
+
}
|
|
265
|
+
refreshWorking(chatID) {
|
|
266
|
+
const chat = this.chat(chatID);
|
|
267
|
+
if (!chat || !this.messenger)
|
|
268
|
+
return;
|
|
269
|
+
const m = this.messenger;
|
|
270
|
+
this.working.set(chatID, new Set(chat.memberIDs.filter((b) => m.isWorking(b))));
|
|
271
|
+
}
|
|
272
|
+
workingBots(chatID) {
|
|
273
|
+
return [...(this.working.get(chatID) ?? [])].sort();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@trycua/cua/spaces/host` — the host presentation adapter. **Explicitly unstable.**
|
|
3
|
+
*
|
|
4
|
+
* This is the one thing the SDK itself cannot do: put a Space on
|
|
5
|
+
* the *operator's own Mac desktop* — pinned as picture-in-picture, opened in a
|
|
6
|
+
* viewer window, or a single Space window streamed back bidirectionally over
|
|
7
|
+
* rcdp.
|
|
8
|
+
*
|
|
9
|
+
* It talks to the loopback control server inside the Cua Spaces desktop app,
|
|
10
|
+
* discovered through `$CUA_HOME/spaces-control.json` (default `~/.cua`; `{port, token}`). That means
|
|
11
|
+
* all of the following, and you should assume all of it will change:
|
|
12
|
+
*
|
|
13
|
+
* - it only works on the machine where the app is running, as the user who
|
|
14
|
+
* owns that home directory;
|
|
15
|
+
* - the port is ephemeral and the token is regenerated on every app launch;
|
|
16
|
+
* - a Local Space's rcdp token rotates on every rcdpd restart, so a token read
|
|
17
|
+
* once and cached will start failing — `streamWindow` takes a fresh one;
|
|
18
|
+
* - the control server resolves `local:<vm>` ids only. `pin` and `openViewer`
|
|
19
|
+
* with any other id are rejected by the app with HTTP 400, and this adapter
|
|
20
|
+
* says so rather than reporting success.
|
|
21
|
+
*
|
|
22
|
+
* Every method throws `host_unavailable` when the app is not running. None of
|
|
23
|
+
* them degrades to a silent no-op — a `forget_rcdp_token` with zero callers is
|
|
24
|
+
* exactly the kind of thing this codebase has shipped before.
|
|
25
|
+
*/
|
|
26
|
+
export interface ControlEndpoint {
|
|
27
|
+
port: number;
|
|
28
|
+
token: string;
|
|
29
|
+
}
|
|
30
|
+
export interface HostPresentationOptions {
|
|
31
|
+
/** Override the discovery file. Mostly for tests. */
|
|
32
|
+
controlFile?: string;
|
|
33
|
+
/** Inject a fetch (tests, or a different agent). */
|
|
34
|
+
fetch?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
35
|
+
}
|
|
36
|
+
/** `$CUA_HOME` when set and non-empty, else `~/.cua` (the Rust core's rule). */
|
|
37
|
+
export declare function cuaHome(home: string, env?: Record<string, string | undefined>): string;
|
|
38
|
+
/** Read `{port, token}` from the app's control file. */
|
|
39
|
+
export declare function readControlEndpoint(controlFile?: string): Promise<ControlEndpoint>;
|
|
40
|
+
export declare class HostPresentation {
|
|
41
|
+
private readonly options;
|
|
42
|
+
constructor(options?: HostPresentationOptions);
|
|
43
|
+
/** Pin a Local Space as picture-in-picture on this Mac's desktop. */
|
|
44
|
+
pin(spaceId: string): Promise<void>;
|
|
45
|
+
unpin(spaceId: string): Promise<void>;
|
|
46
|
+
/** Open the full viewer window for a Local Space. */
|
|
47
|
+
openViewer(spaceId: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Stream one window of a Local Space back to a bidirectional window here.
|
|
50
|
+
*
|
|
51
|
+
* `rcdpToken` must be read fresh: a Local Space's rcdp token rotates on every
|
|
52
|
+
* rcdpd restart, so a cached one silently stops working.
|
|
53
|
+
*
|
|
54
|
+
* `replica: true` opens an *additional* stream for the same target rather
|
|
55
|
+
* than focusing the existing one. Note the real limit: only one page per app
|
|
56
|
+
* can hardware-decode H.264, so a second concurrent stream falls back to an
|
|
57
|
+
* uncompressed codec and is visibly heavier.
|
|
58
|
+
*/
|
|
59
|
+
streamWindow(input: {
|
|
60
|
+
spaceId: string;
|
|
61
|
+
windowId: string;
|
|
62
|
+
rcdpToken: string;
|
|
63
|
+
appName?: string;
|
|
64
|
+
title?: string;
|
|
65
|
+
replica?: boolean;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
/** Is the desktop app reachable right now? */
|
|
68
|
+
available(): Promise<boolean>;
|
|
69
|
+
private assertLocal;
|
|
70
|
+
private post;
|
|
71
|
+
}
|