@egoai/platform 0.1.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/src/types.ts ADDED
@@ -0,0 +1,118 @@
1
+ // src/types.ts
2
+
3
+ /** Inbound RPC request from the bot-runner side. */
4
+ export type RpcRequest = {
5
+ type: "req";
6
+ id: string;
7
+ method: string;
8
+ params: Record<string, unknown>;
9
+ };
10
+
11
+ /** Outbound RPC response sent back to the bot-runner side. */
12
+ export type RpcResponse = {
13
+ type: "res";
14
+ id: string;
15
+ ok: boolean;
16
+ payload?: unknown;
17
+ error?: { message: string };
18
+ };
19
+
20
+ /** Parameters for workspace.read RPC. */
21
+ export type WorkspaceReadParams = {
22
+ sessionKey?: string;
23
+ path?: string;
24
+ recursive?: boolean;
25
+ };
26
+
27
+ /** Parameters for workspace.write RPC. */
28
+ export type WorkspaceWriteParams = {
29
+ sessionKey?: string;
30
+ /** Workspace-relative path to write (required). */
31
+ path: string;
32
+ /** UTF-8 content to write (required). */
33
+ content: string;
34
+ };
35
+
36
+ /** Parameters for workspace.ls RPC. */
37
+ export type WorkspaceLsParams = {
38
+ sessionKey?: string;
39
+ /** Workspace-relative path to list (defaults to root). */
40
+ path?: string;
41
+ recursive?: boolean;
42
+ };
43
+
44
+ /** Single file entry returned by workspace.read. */
45
+ export type FileEntry = {
46
+ name: string;
47
+ content: string;
48
+ };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Platform chat RPC params
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Parameters for platform.chat.send — send a message to an agent on behalf of a user. */
55
+ export type PlatformChatSendParams = {
56
+ /** Platform user ID used to derive the session key. */
57
+ userId: string;
58
+ /** Agent ID to target. Defaults to "main" when omitted. */
59
+ agentId?: string | null;
60
+ /** The message text to send. */
61
+ message: string;
62
+ /** Optional extended thinking prompt. */
63
+ thinking?: string | null;
64
+ /** Client-supplied idempotency key / run ID. */
65
+ runId?: string | null;
66
+ /**
67
+ * If set, the plugin sends incremental `platform.chat.chunk` notification frames
68
+ * to the platform as reply blocks arrive, tagged with this ID.
69
+ * The final RPC response still contains the full accumulated text.
70
+ */
71
+ streamId?: string | null;
72
+ };
73
+
74
+ /**
75
+ * Parameters for platform.chat.send.stream — fire-and-forget streaming variant.
76
+ * Returns immediately with { runId, streamId }; chunks arrive as platform.chat.chunk
77
+ * notifications and completion as platform.chat.done / platform.chat.error.
78
+ */
79
+ export type PlatformChatSendStreamParams = {
80
+ userId: string;
81
+ agentId?: string | null;
82
+ message: string;
83
+ thinking?: string | null;
84
+ runId?: string | null;
85
+ /**
86
+ * When set, routes the message into a threaded session.
87
+ * The session key gets a :thread:{threadId} suffix, and MessageThreadId is
88
+ * populated in the dispatch context. Required for ACP dispatch to work on
89
+ * thread-scoped sessions.
90
+ */
91
+ threadId?: string | null;
92
+ };
93
+
94
+ /** Parameters for platform.chat.history — fetch conversation history for a user. */
95
+ export type PlatformChatHistoryParams = {
96
+ userId: string;
97
+ agentId?: string | null;
98
+ limit?: number | null;
99
+ };
100
+
101
+ /** Parameters for platform.chat.abort — abort an in-flight agent run for a user. */
102
+ export type PlatformChatAbortParams = {
103
+ userId: string;
104
+ agentId?: string | null;
105
+ runId?: string | null;
106
+ };
107
+
108
+ /** Parameters for platform.chat.reset — reset (clear) a user's conversation session. */
109
+ export type PlatformChatResetParams = {
110
+ userId: string;
111
+ agentId?: string | null;
112
+ };
113
+
114
+ /** Parameters for platform.session.resolve — return the computed session key without sending anything. */
115
+ export type PlatformSessionResolveParams = {
116
+ userId: string;
117
+ agentId?: string | null;
118
+ };
@@ -0,0 +1,324 @@
1
+ // src/workspace.ts
2
+
3
+ import type { PluginLogger } from "openclaw/plugin-sdk";
4
+ import path from "node:path";
5
+ import fs from "node:fs/promises";
6
+ import type { Dirent } from "node:fs";
7
+ import type { WorkspaceReadParams, WorkspaceWriteParams, WorkspaceLsParams, FileEntry } from "./types.js";
8
+
9
+ const EXCLUDED_DIRECTORIES = new Set([
10
+ ".git",
11
+ ".hg",
12
+ ".svn",
13
+ "node_modules",
14
+ "dist",
15
+ "build",
16
+ "vendor",
17
+ ]);
18
+
19
+ /**
20
+ * Depth-first directory traversal. Skips symlinks and excluded dirs.
21
+ * Returns sorted workspace-relative file paths.
22
+ */
23
+ export async function collectFiles(root: string, recursive: boolean): Promise<string[]> {
24
+ const results: string[] = [];
25
+ const stack: string[] = [root];
26
+
27
+ while (stack.length > 0) {
28
+ const current = stack.pop()!;
29
+ let entries: Dirent[];
30
+ try {
31
+ entries = await fs.readdir(current, { withFileTypes: true });
32
+ } catch {
33
+ continue;
34
+ }
35
+
36
+ for (const entry of entries) {
37
+ if (entry.isSymbolicLink()) continue;
38
+
39
+ const fullPath = path.join(current, entry.name);
40
+ if (entry.isDirectory()) {
41
+ if (recursive && !EXCLUDED_DIRECTORIES.has(entry.name.toLowerCase())) {
42
+ stack.push(fullPath);
43
+ }
44
+ } else if (entry.isFile()) {
45
+ results.push(fullPath);
46
+ }
47
+ }
48
+ }
49
+
50
+ return results.sort((a, b) =>
51
+ toWorkspaceRelative(a, root).localeCompare(toWorkspaceRelative(b, root)),
52
+ );
53
+ }
54
+
55
+ /** Read a single file as UTF-8, return {name, content} with workspace-relative name. */
56
+ export async function readFileEntry(
57
+ filePath: string,
58
+ workspaceRoot: string,
59
+ ): Promise<FileEntry> {
60
+ const content = await fs.readFile(filePath, "utf8");
61
+ return {
62
+ name: toWorkspaceRelative(filePath, workspaceRoot),
63
+ content,
64
+ };
65
+ }
66
+
67
+ /** Check that a candidate path does not escape the root (path traversal prevention). */
68
+ export function isSubPath(candidate: string, root: string): boolean {
69
+ const relative = path.relative(root, candidate);
70
+ return (
71
+ relative === "" ||
72
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
73
+ );
74
+ }
75
+
76
+ /** Validate that the workspace directory exists. */
77
+ export async function ensureWorkspaceExists(workspaceRoot: string): Promise<void> {
78
+ try {
79
+ const stats = await fs.stat(workspaceRoot);
80
+ if (!stats.isDirectory()) {
81
+ throw new Error(`Workspace path is not a directory`);
82
+ }
83
+ } catch (error) {
84
+ if ((error as { code?: string }).code === "ENOENT") {
85
+ throw new Error(`Workspace directory does not exist`);
86
+ }
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ /** Convert absolute path to workspace-relative with forward slashes. */
92
+ function toWorkspaceRelative(filePath: string, workspaceRoot: string): string {
93
+ const relative = path.relative(workspaceRoot, filePath);
94
+ const normalized = relative === "" ? path.basename(filePath) : relative;
95
+ return normalized.split(path.sep).join("/");
96
+ }
97
+
98
+ /**
99
+ * Resolve workspace root from an agent list using sessionKey.
100
+ *
101
+ * Accepts agents in the format returned by the `agents.list` RPC:
102
+ * `[{id, workspace, ...}, ...]`
103
+ *
104
+ * Resolution order:
105
+ * 1. Agent-specific workspace (from agents list, matched by id)
106
+ * 2. Default workspace (from `agents.defaults.workspace` in config)
107
+ * 3. Error — no workspace available
108
+ */
109
+ export function resolveWorkspace(
110
+ agents: Array<{ id: string; workspace?: string }>,
111
+ sessionKey: string,
112
+ defaultWorkspace?: string,
113
+ ): string {
114
+ const key = sessionKey.trim();
115
+ if (!key) {
116
+ throw new Error("sessionKey is required for workspace resolution");
117
+ }
118
+
119
+ // Parse agentId from "agent:<agentId>:<rest>"
120
+ const parts = key.split(":").filter(Boolean);
121
+ const agentId =
122
+ parts[0]?.toLowerCase() === "agent" && parts[1]
123
+ ? parts[1].toLowerCase()
124
+ : null;
125
+ if (!agentId) {
126
+ throw new Error(`Cannot parse agent ID from sessionKey: ${key}`);
127
+ }
128
+
129
+ // Look up the agent — never fall back to default silently
130
+ const list = Array.isArray(agents) ? agents : [];
131
+ const agentEntry = list.find((a) => a.id.toLowerCase() === agentId);
132
+
133
+ if (!agentEntry) {
134
+ const knownIds = list.map((a) => a.id).join(", ");
135
+ throw new Error(`Agent '${agentId}' not found in agents list [${knownIds}]`);
136
+ }
137
+
138
+ // Use agent-specific workspace; fall back to default only for the default agent (no explicit workspace)
139
+ const workspace = agentEntry.workspace?.trim() || defaultWorkspace?.trim();
140
+
141
+ if (!workspace) {
142
+ throw new Error(`No workspace configured for agent '${agentId}'`);
143
+ }
144
+
145
+ const expanded = workspace.startsWith("~")
146
+ ? workspace.replace("~", process.env.HOME ?? "")
147
+ : workspace;
148
+ return path.resolve(expanded);
149
+ }
150
+
151
+ /**
152
+ * Top-level handler for workspace.read RPC.
153
+ * Returns {ok, payload} or {ok: false, error} — never throws.
154
+ *
155
+ * @param agents - Agent list from `agents.list` RPC: `[{id, workspace, ...}]`
156
+ * @param defaultWorkspace - Fallback workspace from `agents.defaults.workspace`
157
+ */
158
+ export async function handleWorkspaceRead(
159
+ agents: Array<{ id: string; workspace?: string }>,
160
+ params: WorkspaceReadParams,
161
+ logger?: PluginLogger,
162
+ defaultWorkspace?: string,
163
+ ): Promise<{ ok: true; payload: FileEntry[] } | { ok: false; error: string }> {
164
+ try {
165
+ if (!params.sessionKey) {
166
+ return { ok: false, error: "sessionKey is required for workspace.read" };
167
+ }
168
+
169
+ const workspaceRoot = resolveWorkspace(agents, params.sessionKey, defaultWorkspace);
170
+ await ensureWorkspaceExists(workspaceRoot);
171
+
172
+ const recursive = params.recursive !== false;
173
+ const requestedPath = params.path?.trim();
174
+
175
+ logger?.info(
176
+ `workspace.read: path=${requestedPath ?? "."} recursive=${recursive} agent-session=${params.sessionKey}`,
177
+ );
178
+
179
+ let targetPaths: string[];
180
+
181
+ if (requestedPath) {
182
+ const dirPath = path.resolve(workspaceRoot, requestedPath.replace(/\\/g, "/"));
183
+ if (!isSubPath(dirPath, workspaceRoot)) {
184
+ return { ok: false, error: `Path '${requestedPath}' is outside the workspace` };
185
+ }
186
+ let stats;
187
+ try {
188
+ stats = await fs.stat(dirPath);
189
+ } catch {
190
+ return { ok: true, payload: [] };
191
+ }
192
+ if (stats.isDirectory()) {
193
+ targetPaths = await collectFiles(dirPath, recursive);
194
+ } else if (stats.isFile()) {
195
+ targetPaths = [dirPath];
196
+ } else {
197
+ return { ok: true, payload: [] };
198
+ }
199
+ } else {
200
+ targetPaths = await collectFiles(workspaceRoot, recursive);
201
+ }
202
+
203
+ if (targetPaths.length === 0) {
204
+ return { ok: true, payload: [] };
205
+ }
206
+
207
+ const entries: FileEntry[] = [];
208
+ for (const filePath of targetPaths) {
209
+ try {
210
+ entries.push(await readFileEntry(filePath, workspaceRoot));
211
+ } catch (err) {
212
+ logger?.warn(`workspace.read: skipping ${path.relative(workspaceRoot, filePath)}: ${err}`);
213
+ }
214
+ }
215
+
216
+ return { ok: true, payload: entries };
217
+ } catch (err) {
218
+ const message = err instanceof Error ? err.message : String(err);
219
+ return { ok: false, error: message };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Top-level handler for workspace.write RPC.
225
+ * Writes (or overwrites) a single file inside the agent's workspace.
226
+ * Parent directories are created if they don't exist.
227
+ * Returns {ok, payload: {bytes}} or {ok: false, error}.
228
+ */
229
+ export async function handleWorkspaceWrite(
230
+ agents: Array<{ id: string; workspace?: string }>,
231
+ params: WorkspaceWriteParams,
232
+ logger?: PluginLogger,
233
+ defaultWorkspace?: string,
234
+ ): Promise<{ ok: true; payload: { bytes: number } } | { ok: false; error: string }> {
235
+ try {
236
+ if (!params.sessionKey) {
237
+ return { ok: false, error: "sessionKey is required for workspace.write" };
238
+ }
239
+ if (!params.path?.trim()) {
240
+ return { ok: false, error: "path is required for workspace.write" };
241
+ }
242
+ if (typeof params.content !== "string") {
243
+ return { ok: false, error: "content is required for workspace.write" };
244
+ }
245
+
246
+ const workspaceRoot = resolveWorkspace(agents, params.sessionKey, defaultWorkspace);
247
+ await ensureWorkspaceExists(workspaceRoot);
248
+
249
+ const targetPath = path.resolve(workspaceRoot, params.path.trim().replace(/\\/g, "/"));
250
+ if (!isSubPath(targetPath, workspaceRoot)) {
251
+ return { ok: false, error: `Path '${params.path}' is outside the workspace` };
252
+ }
253
+
254
+ logger?.info(
255
+ `workspace.write: path=${params.path} agent-session=${params.sessionKey}`,
256
+ );
257
+
258
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
259
+ await fs.writeFile(targetPath, params.content, "utf8");
260
+
261
+ return { ok: true, payload: { bytes: Buffer.byteLength(params.content, "utf8") } };
262
+ } catch (err) {
263
+ const message = err instanceof Error ? err.message : String(err);
264
+ return { ok: false, error: message };
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Top-level handler for workspace.ls RPC.
270
+ * Lists workspace-relative file paths without reading their content.
271
+ * Returns {ok, payload: string[]} or {ok: false, error}.
272
+ */
273
+ export async function handleWorkspaceLs(
274
+ agents: Array<{ id: string; workspace?: string }>,
275
+ params: WorkspaceLsParams,
276
+ logger?: PluginLogger,
277
+ defaultWorkspace?: string,
278
+ ): Promise<{ ok: true; payload: string[] } | { ok: false; error: string }> {
279
+ try {
280
+ if (!params.sessionKey) {
281
+ return { ok: false, error: "sessionKey is required for workspace.ls" };
282
+ }
283
+
284
+ const workspaceRoot = resolveWorkspace(agents, params.sessionKey, defaultWorkspace);
285
+ await ensureWorkspaceExists(workspaceRoot);
286
+
287
+ const recursive = params.recursive !== false;
288
+ const requestedPath = params.path?.trim();
289
+
290
+ logger?.info(
291
+ `workspace.ls: path=${requestedPath ?? "."} recursive=${recursive} agent-session=${params.sessionKey}`,
292
+ );
293
+
294
+ let targetDir: string;
295
+ if (requestedPath) {
296
+ targetDir = path.resolve(workspaceRoot, requestedPath.replace(/\\/g, "/"));
297
+ if (!isSubPath(targetDir, workspaceRoot)) {
298
+ return { ok: false, error: `Path '${requestedPath}' is outside the workspace` };
299
+ }
300
+ } else {
301
+ targetDir = workspaceRoot;
302
+ }
303
+
304
+ let stats;
305
+ try {
306
+ stats = await fs.stat(targetDir);
307
+ } catch {
308
+ return { ok: true, payload: [] };
309
+ }
310
+ if (!stats.isDirectory()) {
311
+ return { ok: false, error: `Path '${requestedPath}' is not a directory` };
312
+ }
313
+
314
+ const filePaths = await collectFiles(targetDir, recursive);
315
+ const relativePaths = filePaths.map((p) =>
316
+ path.relative(workspaceRoot, p).split(path.sep).join("/"),
317
+ );
318
+
319
+ return { ok: true, payload: relativePaths };
320
+ } catch (err) {
321
+ const message = err instanceof Error ? err.message : String(err);
322
+ return { ok: false, error: message };
323
+ }
324
+ }