@meetopenbot/codex 1.0.12 → 1.1.1

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 CHANGED
@@ -1,37 +1,23 @@
1
- # openbot-plugin-codex
1
+ # @meetopenbot/codex
2
2
 
3
- Minimal Codex plugin scaffold for OpenBot.
3
+ OpenAI Codex runtime plugin for OpenBot. Each `agent:invoke` starts or resumes a Codex SDK thread in the channel workspace.
4
4
 
5
- ## What is included
5
+ ## Config
6
6
 
7
- - A plugin registry export (`plugin`) compatible with OpenBot
8
- - One tool definition: `codex_run`
9
- - One action handler: `action:codex_run`
10
- - `@openai/codex-sdk` integration via `Codex` + `thread.run(...)`
7
+ Set an API key with one of:
11
8
 
12
- ## Local usage
13
-
14
- 1. Install dependencies:
15
-
16
- `npm install`
17
-
18
- 2. Build:
19
-
20
- `npm run build`
21
-
22
- 3. Load `dist/index.js` from your OpenBot plugin registry/runtime.
23
-
24
- ## Runtime config
25
-
26
- Set an API key using one of the following:
27
-
28
- - `CODEX_API_KEY` environment variable
29
- - `OPENAI_API_KEY` environment variable
30
9
  - `apiKey` in plugin options
10
+ - `CODEX_API_KEY`
11
+ - `OPENAI_API_KEY`
12
+
13
+ Optional:
31
14
 
32
- Optional plugin options:
15
+ - `model` — Codex model id (e.g. `gpt-5.6`). Omit to use the CLI default.
16
+ - `sandboxMode` — `read-only` | `workspace-write` | `danger-full-access` (default `workspace-write`)
17
+ - `approvalPolicy` — `never` | `on-request` | `on-failure` | `untrusted` (default `never`)
18
+ - `workingDirectory` — override the channel cwd
19
+ - `skipGitRepoCheck` — default `true` for non-git channel workspaces
20
+ - `baseURL` — custom OpenAI-compatible endpoint
21
+ - `codexPathOverride` — path to a local `codex` binary (`CODEX_PATH` / `CODEX_CLI_PATH` also work)
33
22
 
34
- - `model` (defaults to `gpt-5-codex`)
35
- - `baseURL` (for custom OpenAI-compatible endpoints)
36
- - `workingDirectory`, `skipGitRepoCheck`, `sandboxMode`, `approvalPolicy`
37
- - `networkAccessEnabled`, `webSearchMode`, `threadId`
23
+ Codex conversation state is stored as `codexThreadId` on the OpenBot thread, not as the OpenBot thread id.
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from '@meetopenbot/plugin-sdk';
2
+ declare const plugin: Plugin;
3
+ export { plugin };
4
+ export default plugin;
package/dist/index.js CHANGED
@@ -3,185 +3,326 @@ import {
3
3
  definePlugin,
4
4
  shouldHandleInvoke,
5
5
  agentOutput,
6
- uiWidget
6
+ uiWidget,
7
+ toolTraceWidget,
8
+ buildDiffWidget,
9
+ diffFileFromWrite,
10
+ resolveRunDiffFiles,
11
+ snapshotWorkspace
7
12
  } from "@meetopenbot/plugin-sdk";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
8
15
  import {
9
16
  Codex
10
17
  } from "@openai/codex-sdk";
11
- var codex_default = definePlugin({
18
+ var SANDBOX_MODES = [
19
+ "read-only",
20
+ "workspace-write",
21
+ "danger-full-access"
22
+ ];
23
+ var APPROVAL_MODES = [
24
+ "never",
25
+ "on-request",
26
+ "on-failure",
27
+ "untrusted"
28
+ ];
29
+ var LEGACY_SANDBOX = {
30
+ "workspace-read": "read-only",
31
+ "full-read": "read-only",
32
+ "full-write": "danger-full-access"
33
+ };
34
+ var LEGACY_APPROVAL = {
35
+ always: "on-request",
36
+ automatic: "on-request"
37
+ };
38
+ var AUTH_ERROR_PATTERNS = [
39
+ "api key",
40
+ "apikey",
41
+ "unauthorized",
42
+ "401",
43
+ "authentication",
44
+ "not logged in",
45
+ "login"
46
+ ];
47
+ var asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
48
+ var asString = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
49
+ var isAuthErrorMessage = (message) => {
50
+ const lower = message.toLowerCase();
51
+ return AUTH_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
52
+ };
53
+ var readPersistedThreadId = (state) => {
54
+ const source = state.threadDetails?.state ?? state.channelDetails?.state;
55
+ const record = asRecord(source);
56
+ return asString(record.codexThreadId);
57
+ };
58
+ var persistThreadId = async (state, storage, codexThreadId) => {
59
+ if (!storage || !state.channelId) return;
60
+ const patch = { codexThreadId };
61
+ if (state.threadId) {
62
+ await storage.patchThreadState({
63
+ channelId: state.channelId,
64
+ threadId: state.threadId,
65
+ state: patch
66
+ });
67
+ return;
68
+ }
69
+ await storage.patchChannelState({ channelId: state.channelId, state: patch });
70
+ };
71
+ var parseSandboxMode = (value) => {
72
+ const raw = asString(value);
73
+ if (!raw) return "workspace-write";
74
+ if (SANDBOX_MODES.includes(raw)) return raw;
75
+ return LEGACY_SANDBOX[raw] ?? "workspace-write";
76
+ };
77
+ var parseApprovalPolicy = (value) => {
78
+ const raw = asString(value);
79
+ if (!raw) return "never";
80
+ if (APPROVAL_MODES.includes(raw)) return raw;
81
+ return LEGACY_APPROVAL[raw] ?? "never";
82
+ };
83
+ var parseModel = (value) => {
84
+ const raw = asString(value);
85
+ if (!raw) return void 0;
86
+ return raw.split("/").pop() || void 0;
87
+ };
88
+ var itemWidget = (item) => {
89
+ switch (item.type) {
90
+ case "command_execution":
91
+ return {
92
+ title: "Command",
93
+ body: [item.command, item.aggregated_output].filter(Boolean).join("\n\n")
94
+ };
95
+ case "file_change":
96
+ return {
97
+ title: "File change",
98
+ body: item.changes.map((change) => `${change.kind} ${change.path}`).join("\n")
99
+ };
100
+ case "mcp_tool_call":
101
+ return {
102
+ title: `${item.server}: ${item.tool}`,
103
+ body: item.error?.message ?? (item.result ? JSON.stringify(item.result, null, 2) : "")
104
+ };
105
+ case "web_search":
106
+ return { title: "Web search", body: item.query };
107
+ case "error":
108
+ return { title: "Error", body: item.message };
109
+ default:
110
+ return null;
111
+ }
112
+ };
113
+ var collectCodexChange = (path, kind, cwd, files) => {
114
+ if (kind === "add") {
115
+ try {
116
+ files.set(path, diffFileFromWrite(path, readFileSync(join(cwd, path), "utf8")));
117
+ return;
118
+ } catch {
119
+ }
120
+ }
121
+ files.set(path, {
122
+ path,
123
+ status: kind === "delete" ? "deleted" : kind === "add" ? "added" : "modified"
124
+ });
125
+ };
126
+ var errorText = (error) => error instanceof Error ? error.message : "Codex request failed.";
127
+ var buildApiKeyWidget = (agentId, threadId, reason) => uiWidget({
128
+ agentId,
129
+ threadId,
130
+ widget: {
131
+ kind: "form",
132
+ widgetId: `codex_api_key_request_${Date.now()}`,
133
+ title: "OpenAI API Key Required",
134
+ description: `Codex could not authenticate (${reason}). Provide an OpenAI API key to continue. You can get one from the OpenAI dashboard. The key is stored as a workspace variable on your machine and never leaves your local runtime.`,
135
+ fields: [
136
+ {
137
+ id: "apiKey",
138
+ label: "API Key",
139
+ type: "password",
140
+ placeholder: "sk-...",
141
+ required: true
142
+ }
143
+ ],
144
+ submitLabel: "Save API Key",
145
+ metadata: {
146
+ type: "api_key_request",
147
+ provider: "openai",
148
+ envVar: "OPENAI_API_KEY",
149
+ source: "codex"
150
+ }
151
+ }
152
+ });
153
+ var plugin = definePlugin({
12
154
  id: "codex",
13
155
  name: "Codex",
14
- description: "Codex integration tools for OpenBot",
156
+ description: "OpenAI Codex agent. Uses the Codex SDK to read code, edit files, and run shell commands inside the channel's workspace.",
15
157
  configSchema: {
16
158
  type: "object",
17
159
  properties: {
18
- apiKey: { type: "string", description: "Codex API Key", format: "password" },
19
- baseURL: { type: "string", description: "Custom OpenAI-compatible endpoint" },
20
- codexPathOverride: { type: "string", description: "Path to codex CLI" },
21
- model: { type: "string", description: "Model to use", default: "gpt-5-codex" },
22
- workingDirectory: { type: "string", description: "Working directory for Codex" },
23
- skipGitRepoCheck: { type: "boolean", description: "Skip git repo check", default: true },
160
+ apiKey: {
161
+ type: "string",
162
+ description: "OpenAI API key (falls back to CODEX_API_KEY / OPENAI_API_KEY)",
163
+ format: "password"
164
+ },
165
+ model: {
166
+ type: "string",
167
+ description: "Codex model id (e.g. gpt-5.6). Leave empty to use the CLI default."
168
+ },
24
169
  sandboxMode: {
25
170
  type: "string",
26
- enum: ["workspace-read", "workspace-write", "full-read", "full-write"],
27
- description: "Sandbox mode",
171
+ enum: [...SANDBOX_MODES],
172
+ description: "Filesystem sandbox: read-only | workspace-write | danger-full-access",
28
173
  default: "workspace-write"
29
174
  },
30
175
  approvalPolicy: {
31
176
  type: "string",
32
- enum: ["always", "never", "automatic"],
33
- description: "Approval policy",
177
+ enum: [...APPROVAL_MODES],
178
+ description: "When to require approval: never | on-request | on-failure | untrusted",
34
179
  default: "never"
35
180
  },
36
- networkAccessEnabled: { type: "boolean", description: "Enable network access" },
37
- webSearchMode: {
181
+ workingDirectory: {
38
182
  type: "string",
39
- enum: ["always", "never", "automatic"],
40
- description: "Web search mode"
183
+ description: "Override the channel working directory"
184
+ },
185
+ skipGitRepoCheck: {
186
+ type: "boolean",
187
+ description: "Skip the Codex git-repo check (needed for non-git channel workspaces)",
188
+ default: true
189
+ },
190
+ baseURL: {
191
+ type: "string",
192
+ description: "Custom OpenAI-compatible endpoint"
193
+ },
194
+ codexPathOverride: {
195
+ type: "string",
196
+ description: "Path to a local Codex CLI binary"
41
197
  }
42
198
  }
43
199
  },
44
200
  factory: (context) => {
45
201
  const config = context.config;
46
- const env = globalThis?.process?.env || {};
47
- const apiKey = config.apiKey ?? env.CODEX_API_KEY ?? env.OPENAI_API_KEY;
48
- const codexPathOverride = config.codexPathOverride ?? env.CODEX_PATH ?? env.CODEX_CLI_PATH;
49
- const model = config.model?.split("/").pop() || "gpt-5-codex";
202
+ const apiKey = asString(config.apiKey) ?? process.env.CODEX_API_KEY ?? process.env.OPENAI_API_KEY;
203
+ const codexPathOverride = asString(config.codexPathOverride) ?? process.env.CODEX_PATH ?? process.env.CODEX_CLI_PATH;
204
+ const model = parseModel(config.model);
205
+ const sandboxMode = parseSandboxMode(config.sandboxMode);
206
+ const approvalPolicy = parseApprovalPolicy(config.approvalPolicy);
207
+ const skipGitRepoCheck = config.skipGitRepoCheck !== false;
208
+ const workingDirectoryOverride = asString(config.workingDirectory);
209
+ const baseUrl = asString(config.baseURL);
50
210
  let client;
51
211
  const getClient = () => {
52
212
  if (!client) {
53
213
  client = new Codex({
54
- apiKey,
214
+ ...apiKey && { apiKey },
55
215
  ...codexPathOverride && { codexPathOverride },
56
- ...config.baseURL && { baseUrl: config.baseURL }
216
+ ...baseUrl && { baseUrl }
57
217
  });
58
218
  }
59
219
  return client;
60
220
  };
61
- let thread = null;
62
- const getThread = (state, meta) => {
63
- if (thread) return thread;
64
- const workingDirectory = config.workingDirectory || state?.channelDetails?.cwd || globalThis?.process?.cwd() || "/tmp";
65
- const threadOptions = {
66
- model,
67
- workingDirectory,
68
- skipGitRepoCheck: config.skipGitRepoCheck ?? true,
69
- sandboxMode: config.sandboxMode ?? "workspace-write",
70
- approvalPolicy: config.approvalPolicy ?? "never",
71
- ...typeof config.networkAccessEnabled === "boolean" && {
72
- networkAccessEnabled: config.networkAccessEnabled
73
- },
74
- ...config.webSearchMode && {
75
- webSearchMode: config.webSearchMode
76
- }
77
- };
78
- const threadId = state?.threadId || meta?.threadId;
79
- thread = threadId ? getClient().resumeThread(threadId, threadOptions) : getClient().startThread(threadOptions);
80
- if (!threadId && state) {
81
- state.threadId = thread.id;
82
- }
83
- return thread;
84
- };
85
221
  return (builder) => {
86
222
  builder.on("agent:invoke", async function* (event, ctx) {
87
223
  if (!shouldHandleInvoke(event, context.agentId)) return;
88
- const { content } = event.data || {};
224
+ const content = asString(event.data?.content);
225
+ const openbotThreadId = event.meta?.threadId || ctx.state.threadId;
89
226
  if (!content) {
90
227
  yield agentOutput({
91
228
  agentId: context.agentId,
92
229
  content: "No content provided.",
93
- threadId: event.meta?.threadId
230
+ threadId: openbotThreadId
94
231
  });
95
232
  return;
96
233
  }
234
+ const workingDirectory = workingDirectoryOverride || ctx.state.channelDetails?.cwd || process.cwd();
235
+ const threadOptions = {
236
+ workingDirectory,
237
+ skipGitRepoCheck,
238
+ sandboxMode,
239
+ approvalPolicy,
240
+ ...model && { model }
241
+ };
242
+ const savedId = readPersistedThreadId(ctx.state);
243
+ const thread = savedId ? getClient().resumeThread(savedId, threadOptions) : getClient().startThread(threadOptions);
244
+ const fail = function* (message) {
245
+ if (isAuthErrorMessage(message)) {
246
+ yield buildApiKeyWidget(context.agentId, openbotThreadId, message);
247
+ }
248
+ yield agentOutput({
249
+ agentId: context.agentId,
250
+ content: `Error: ${message}`,
251
+ threadId: openbotThreadId
252
+ });
253
+ };
97
254
  try {
98
- const turn = await getThread(ctx?.state, event.meta).runStreamed(content);
99
- for await (const chunk of turn.events) {
100
- if (chunk.type === "item.completed") {
101
- const { item } = chunk;
102
- if (item.type === "agent_message") {
255
+ const { events } = await thread.runStreamed(content, {
256
+ signal: context.abortSignal
257
+ });
258
+ const snapshot = snapshotWorkspace(workingDirectory);
259
+ const changedFiles = /* @__PURE__ */ new Map();
260
+ for await (const chunk of events) {
261
+ if (chunk.type === "thread.started") {
262
+ await persistThreadId(ctx.state, context.storage, chunk.thread_id);
263
+ continue;
264
+ }
265
+ if (chunk.type === "turn.failed") {
266
+ yield* fail(chunk.error.message);
267
+ return;
268
+ }
269
+ if (chunk.type === "error") {
270
+ yield* fail(chunk.message);
271
+ return;
272
+ }
273
+ if (chunk.type !== "item.started" && chunk.type !== "item.completed") continue;
274
+ if (chunk.item.type === "agent_message") {
275
+ if (chunk.type === "item.completed") {
103
276
  yield agentOutput({
104
277
  agentId: context.agentId,
105
- content: item.text,
106
- threadId: event.meta?.threadId
278
+ content: chunk.item.text,
279
+ threadId: openbotThreadId
107
280
  });
108
- continue;
109
281
  }
110
- let title = "";
111
- let body = "";
112
- switch (item.type) {
113
- case "reasoning":
114
- title = "Reasoning";
115
- body = item.text;
116
- break;
117
- case "command_execution":
118
- title = "Command Execution";
119
- body = `Executing: ${item.command}`;
120
- break;
121
- case "file_change":
122
- title = "File Change";
123
- body = item.changes.map((change) => `${change.path}: ${change.kind || change.action}`).join("\n");
124
- break;
125
- case "mcp_tool_call":
126
- title = `Tool: ${item.tool}`;
127
- body = `Arguments: ${JSON.stringify(item.arguments, null, 2)}`;
128
- if (item.result) {
129
- body += `
130
-
131
- Result: ${JSON.stringify(
132
- item.result.structured_content || item.result.content,
133
- null,
134
- 2
135
- )}`;
136
- }
137
- if (item.error) {
138
- body += `
139
-
140
- Error: ${item.error.message}`;
141
- }
142
- break;
143
- case "web_search":
144
- title = "Web Search";
145
- body = `Searching: ${item.query}`;
146
- break;
147
- case "todo_list":
148
- title = "Todo List";
149
- body = item.items.map(
150
- (todo) => `- ${todo.text} (${todo.completed ? "completed" : "pending"})`
151
- ).join("\n");
152
- break;
153
- case "error":
154
- title = "Error";
155
- body = item.message;
156
- break;
157
- }
158
- if (title && body) {
159
- yield uiWidget({
160
- agentId: context.agentId,
161
- threadId: event.meta?.threadId,
162
- widget: {
163
- kind: "message",
164
- title,
165
- body,
166
- // @ts-ignore
167
- variant: "basic",
168
- display: "collapsed"
169
- }
170
- });
282
+ continue;
283
+ }
284
+ if (chunk.type === "item.completed" && chunk.item.type === "file_change" && chunk.item.status === "completed") {
285
+ for (const change of chunk.item.changes) {
286
+ collectCodexChange(change.path, change.kind, workingDirectory, changedFiles);
171
287
  }
172
288
  }
289
+ const widget = itemWidget(chunk.item);
290
+ if (!widget) continue;
291
+ yield uiWidget({
292
+ agentId: context.agentId,
293
+ threadId: openbotThreadId,
294
+ widget: toolTraceWidget({
295
+ widgetId: `codex_${chunk.item.id}`,
296
+ groupId: "codex:tools",
297
+ title: widget.title,
298
+ body: widget.body,
299
+ state: chunk.type === "item.completed" && (chunk.item.type === "command_execution" && chunk.item.status === "failed" || chunk.item.type === "file_change" && chunk.item.status === "failed" || chunk.item.type === "mcp_tool_call" && chunk.item.status === "failed" || chunk.item.type === "error") ? "error" : chunk.type === "item.completed" ? "submitted" : void 0
300
+ })
301
+ });
173
302
  }
174
- } catch (error) {
175
- yield agentOutput({
176
- agentId: context.agentId,
177
- content: `Error: ${error?.message || "Codex request failed."}`,
178
- threadId: event.meta?.threadId
303
+ const diff = buildDiffWidget({
304
+ widgetId: `codex-diff:${openbotThreadId ?? "run"}:${Date.now()}`,
305
+ files: resolveRunDiffFiles({
306
+ snapshot,
307
+ fallback: changedFiles.values()
308
+ })
179
309
  });
310
+ if (diff) {
311
+ yield uiWidget({
312
+ agentId: context.agentId,
313
+ threadId: openbotThreadId,
314
+ widget: diff
315
+ });
316
+ }
317
+ } catch (error) {
318
+ yield* fail(errorText(error));
180
319
  }
181
320
  });
182
321
  };
183
322
  }
184
323
  });
324
+ var plugin_codex_default = plugin;
185
325
  export {
186
- codex_default as default
326
+ plugin_codex_default as default,
327
+ plugin
187
328
  };
package/package.json CHANGED
@@ -1,30 +1,35 @@
1
1
  {
2
2
  "name": "@meetopenbot/codex",
3
- "version": "1.0.12",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
- "description": "Codex tools plugin for OpenBot",
5
+ "description": "OpenAI Codex agent plugin for OpenBot",
6
6
  "main": "./dist/index.js",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
10
10
  "exports": {
11
- ".": "./dist/index.js"
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
12
16
  },
13
17
  "files": [
14
- "dist",
15
- "assets"
18
+ "dist"
16
19
  ],
17
- "scripts": {
18
- "build": "esbuild index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@meetopenbot/plugin-sdk --external:@openai/codex-sdk --external:zod",
19
- "prepublishOnly": "npm run build"
20
- },
21
20
  "dependencies": {
22
- "@meetopenbot/plugin-sdk": "^0.1.2",
23
- "@openai/codex-sdk": "^0.138.0"
21
+ "@openai/codex-sdk": "0.148.0",
22
+ "@meetopenbot/plugin-sdk": "^0.2.0"
24
23
  },
25
24
  "devDependencies": {
26
25
  "@types/node": "^25.6.0",
27
26
  "esbuild": "^0.21.0",
28
27
  "zod": "^4.4.3"
28
+ },
29
+ "types": "./dist/index.d.ts",
30
+ "scripts": {
31
+ "build": "esbuild index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@meetopenbot/plugin-sdk --external:@openai/codex-sdk --external:zod && node ../../scripts/write-plugin-declaration.mjs",
32
+ "dev": "esbuild index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@meetopenbot/plugin-sdk --external:@openai/codex-sdk --external:zod --watch",
33
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --module ESNext --moduleResolution Bundler --target ES2022 --skipLibCheck index.ts"
29
34
  }
30
- }
35
+ }
package/assets/icon.svg DELETED
@@ -1 +0,0 @@
1
- <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>