@jaybeeuu/agent-cortex 2.3.0 → 2.4.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.
@@ -1,30 +1,29 @@
1
1
  /**
2
- * Auto-name sessions using a tiny side-agent LLM.
2
+ * Auto-name sessions from the first user prompt.
3
3
  *
4
- * On the first user prompt of a new session, calls a cheap configured model
5
- * (default: opencode/mimo-v2.5-free) to generate a concise session title,
6
- * then sets it via pi.setSessionName(). Falls back to simple truncation if
7
- * the LLM call fails.
4
+ * On the first prompt of a new session, names the session with a trimmed
5
+ * version of that prompt, then sets it via pi.setSessionName(). Existing names
6
+ * (set via --name or /name) are never overwritten.
7
+ *
8
+ * The name used to come from a cheap side-model, which needed pi-ai's
9
+ * `complete()` — a value import that stopped this extension loading outside the
10
+ * agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
11
+ * that already served as its fallback is now the only path; `tinyModel.enabled`
12
+ * still turns naming off entirely.
8
13
  *
9
14
  * Config (in settings.json under `tinyModel`):
10
15
  * ```json
11
16
  * {
12
17
  * "tinyModel": {
13
- * "model": "opencode/mimo-v2.5-free",
18
+ * "enabled": true,
14
19
  * "maxNameLength": 50
15
20
  * }
16
21
  * }
17
22
  * ```
18
- *
19
- * Existing names (set via --name or /name) are never overwritten.
20
23
  */
21
24
 
22
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
- import {
24
- loadTinyModelConfig,
25
- parseModelString,
26
- generateNameFromPrompt,
27
- } from "./lib/tiny-model.js";
26
+ import { loadTinyModelConfig } from "./lib/tiny-model.js";
28
27
  import type { TinyModelConfig } from "./lib/tiny-model.js";
29
28
 
30
29
  export default function (pi: ExtensionAPI) {
@@ -41,7 +40,7 @@ export default function (pi: ExtensionAPI) {
41
40
  }
42
41
  });
43
42
 
44
- pi.on("input", async (event, ctx) => {
43
+ pi.on("input", async (event, _ctx) => {
45
44
  if (!config.enabled || hasNamed) return;
46
45
  if (event.source === "rpc") return;
47
46
  if (pi.getSessionName()) {
@@ -51,46 +50,14 @@ export default function (pi: ExtensionAPI) {
51
50
  if (!event.text?.trim()) return;
52
51
 
53
52
  hasNamed = true;
54
-
55
- // Fire the LLM call asynchronously — don't block the agent
56
- (async () => {
57
- try {
58
- const { provider, id } = parseModelString(config.model);
59
- const model = ctx.modelRegistry.find(provider, id);
60
- if (!model) {
61
- // Model not found; fall back to truncation
62
- fallbackName(event.text, config.maxNameLength, pi);
63
- return;
64
- }
65
-
66
- const { apiKey, headers } =
67
- await ctx.modelRegistry.getApiKeyAndHeaders(model);
68
-
69
- const name = await generateNameFromPrompt(
70
- model,
71
- apiKey,
72
- headers,
73
- event.text,
74
- config.maxNameLength,
75
- );
76
-
77
- if (name) {
78
- pi.setSessionName(name);
79
- } else {
80
- fallbackName(event.text, config.maxNameLength, pi);
81
- }
82
- } catch (err) {
83
- console.warn("[auto-name] LLM call failed, falling back:", err);
84
- fallbackName(event.text, config.maxNameLength, pi);
85
- }
86
- })();
53
+ nameFromPrompt(event.text, config.maxNameLength, pi);
87
54
  });
88
55
  }
89
56
 
90
57
  /**
91
- * Fallback: truncate the first line of the prompt to use as a session name.
58
+ * Name the session with the first line of the prompt, truncated to `maxLength`.
92
59
  */
93
- function fallbackName(
60
+ function nameFromPrompt(
94
61
  text: string,
95
62
  maxLength: number,
96
63
  pi: ExtensionAPI,
@@ -1,43 +1,45 @@
1
1
  /**
2
- * Shared helpers for making cheap LLM calls from extensions.
2
+ * The `tinyModel` config block shared by the extensions that used to call a
3
+ * cheap side-model: session naming (auto-name) and response summaries (notify).
3
4
  *
4
- * Reads a `tinyModel` config key from settings.json (global + project merged)
5
- * and provides functions for session naming and response summarization using
6
- * the configured model — typically the tiniest free model available.
7
- *
8
- * Config (in ~/.pi/agent/settings.json or .pi/settings.json):
5
+ * Reads the key from settings.json (global + project merged):
9
6
  * ```json
10
7
  * {
11
8
  * "tinyModel": {
12
- * "model": "opencode/mimo-v2.5-free",
9
+ * "enabled": true,
13
10
  * "maxNameLength": 50,
14
11
  * "maxSummaryLength": 100
15
12
  * }
16
13
  * }
17
14
  * ```
15
+ *
16
+ * The model call itself is gone. It went through pi-ai's `complete()`, a value
17
+ * import of pi's runtime, and that import stopped the extension loading outside
18
+ * the agent-cortex checkout (see test/pi-extensions-load.test.mjs). The
19
+ * ExtensionAPI promises no completion call. Newer hosts (pi 1.4.0) expose
20
+ * `ModelRegistry.complete`, but the installed host (0.73.1) does not. Calling it
21
+ * would need an untyped cast to a method that exists on one version only.
22
+ * Extensions also make no internal LLM calls (AGENTS.md), so this feature stays
23
+ * off. Both callers fall back to the truncation they already used when the model
24
+ * failed. The `model` key is no longer read and is ignored if present.
18
25
  */
19
26
 
20
- import { complete } from "@earendil-works/pi-ai";
21
27
  import { homedir } from "node:os";
22
28
  import { join } from "node:path";
23
29
  import { readFileSync, existsSync } from "node:fs";
24
- import type { Model, Api } from "@earendil-works/pi-ai";
25
30
 
26
31
  /* ── Types ── */
27
32
 
28
33
  export interface TinyModelConfig {
29
- /** Provider/id string, e.g. "opencode/mimo-v2.5-free" */
30
- model: string;
31
34
  /** Global on/off toggle */
32
35
  enabled: boolean;
33
- /** Max characters for auto-generated session names */
36
+ /** Max characters for generated session names */
34
37
  maxNameLength: number;
35
38
  /** Max characters for response summaries in notifications */
36
39
  maxSummaryLength: number;
37
40
  }
38
41
 
39
42
  const DEFAULT_CONFIG: TinyModelConfig = {
40
- model: "opencode/mimo-v2.5-free",
41
43
  enabled: true,
42
44
  maxNameLength: 40,
43
45
  maxSummaryLength: 100,
@@ -72,141 +74,3 @@ export function loadTinyModelConfig(cwd?: string): TinyModelConfig {
72
74
 
73
75
  return { ...DEFAULT_CONFIG, ...merged } as TinyModelConfig;
74
76
  }
75
-
76
- /**
77
- * Parse a "provider/id" string into its parts.
78
- */
79
- export function parseModelString(modelStr: string): {
80
- provider: string;
81
- id: string;
82
- } {
83
- const slash = modelStr.indexOf("/");
84
- if (slash === -1) return { provider: "opencode", id: modelStr };
85
- return {
86
- provider: modelStr.slice(0, slash),
87
- id: modelStr.slice(slash + 1),
88
- };
89
- }
90
-
91
- /* ── LLM helpers ── */
92
-
93
- type ModelAny = Model<Api>;
94
- type CompleteOptions = Record<string, any>;
95
-
96
- /**
97
- * Generate a concise session title from the user's first prompt.
98
- *
99
- * Calls the configured tiny model with a system prompt tuned for naming.
100
- * Returns null on failure (caller should fall back to truncation).
101
- */
102
- export async function generateNameFromPrompt(
103
- model: ModelAny,
104
- apiKey: string | undefined,
105
- headers: Record<string, string> | undefined,
106
- prompt: string,
107
- maxLength: number,
108
- ): Promise<string | null> {
109
- const systemPrompt = [
110
- "You are a session naming assistant. Generate a concise title for a coding session.",
111
- "",
112
- "Rules:",
113
- "- Output ONLY the title, no quotes, no prefix, no explanation",
114
- `- Maximum ${maxLength} characters`,
115
- "- Summarize intent, do not copy verbatim",
116
- "- Keep it short (3\u20135 words), capture the general theme",
117
- "- If the message mentions files or modules, keep the key names brief",
118
- ].join("\n");
119
-
120
- const truncated = prompt.length > 2000 ? prompt.slice(0, 2000) + "..." : prompt;
121
-
122
- try {
123
- const options: CompleteOptions = { maxTokens: 64 };
124
- if (apiKey) options.apiKey = apiKey;
125
- if (headers) options.headers = headers;
126
-
127
- const result = await complete(model, {
128
- systemPrompt,
129
- messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
130
- }, options);
131
-
132
- const text = result.content
133
- ?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
134
- ?.map((b) => b.text)
135
- ?.join("")
136
- ?.trim() ?? "";
137
-
138
- return cleanTitle(text, maxLength);
139
- } catch {
140
- return null;
141
- }
142
- }
143
-
144
- /**
145
- * Summarize an assistant response into a one-line summary.
146
- *
147
- * Calls the configured tiny model. Returns null on failure.
148
- */
149
- export async function summarizeResponseText(
150
- model: ModelAny,
151
- apiKey: string | undefined,
152
- headers: Record<string, string> | undefined,
153
- responseText: string,
154
- maxLength: number,
155
- ): Promise<string | null> {
156
- const systemPrompt = [
157
- "Summarize what the AI assistant did in this coding session response.",
158
- "Be concise — one short sentence or phrase.",
159
- `Maximum ${maxLength} characters.`,
160
- "Focus on what changed or was accomplished.",
161
- "Output ONLY the summary, no quotes, no prefix, no explanation.",
162
- ].join("\n");
163
-
164
- const truncated =
165
- responseText.length > 2000 ? responseText.slice(0, 2000) + "..." : responseText;
166
-
167
- try {
168
- const options: CompleteOptions = { maxTokens: 64 };
169
- if (apiKey) options.apiKey = apiKey;
170
- if (headers) options.headers = headers;
171
-
172
- const result = await complete(model, {
173
- systemPrompt,
174
- messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
175
- }, options);
176
-
177
- const text = result.content
178
- ?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
179
- ?.map((b) => b.text)
180
- ?.join("")
181
- ?.trim() ?? "";
182
-
183
- return text || null;
184
- } catch {
185
- return null;
186
- }
187
- }
188
-
189
- /* ── Internal helpers ── */
190
-
191
- function cleanTitle(raw: string, maxLength: number): string | null {
192
- let name = raw.trim();
193
- if (!name) return null;
194
-
195
- // Strip surrounding quotes if present
196
- if (
197
- (name.startsWith('"') && name.endsWith('"')) ||
198
- (name.startsWith("'") && name.endsWith("'"))
199
- ) {
200
- name = name.slice(1, -1);
201
- }
202
-
203
- // Collapse newlines
204
- name = name.replace(/\n/g, " ").trim();
205
-
206
- // Truncate
207
- if (name.length > maxLength) {
208
- name = name.slice(0, maxLength - 3) + "...";
209
- }
210
-
211
- return name || null;
212
- }
@@ -5,13 +5,16 @@
5
5
  * multiline notification showing:
6
6
  * Line 1: working directory path
7
7
  * Line 2: session name (if set)
8
- * Line 3: LLM-generated summary of what the agent did
8
+ * Line 3: truncated summary of what the agent did
9
9
  *
10
10
  * The notification title is the tmux session:window.pane identifier (when
11
11
  * available) or "pi".
12
12
  *
13
- * Response summarization uses the tiny model configured in settings.json
14
- * under the `tinyModel` key.
13
+ * The summary used to come from a cheap side-model, which needed pi-ai's
14
+ * `complete()` — a value import that stopped this extension loading outside the
15
+ * agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
16
+ * that already served as its fallback is now the only path; `maxSummaryLength`
17
+ * still sets its length.
15
18
  */
16
19
 
17
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -19,11 +22,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
22
  import { execSync } from "node:child_process";
20
23
  import { homedir } from "node:os";
21
24
 
22
- import {
23
- loadTinyModelConfig,
24
- parseModelString,
25
- summarizeResponseText,
26
- } from "../lib/tiny-model.js";
25
+ import { loadTinyModelConfig } from "../lib/tiny-model.js";
27
26
  import type { TinyModelConfig } from "../lib/tiny-model.js";
28
27
 
29
28
  /* ── Context helpers ── */
@@ -215,20 +214,10 @@ export default function (pi: ExtensionAPI) {
215
214
  const sessionName = pi.getSessionName();
216
215
  if (sessionName) sections.push(sessionName);
217
216
 
218
- // Section 2: LLM-generated summary of the assistant's response
217
+ // Section 2: truncated summary of the assistant's response
219
218
  const responseText = extractAssistantText(event.messages);
220
219
  if (responseText) {
221
- try {
222
- const summary = await summarizeResponseWithFallback(
223
- ctx,
224
- responseText,
225
- config,
226
- );
227
- sections.push(summary);
228
- } catch {
229
- // If summary fails (e.g. config not loaded), just show raw text
230
- sections.push(responseText.slice(0, config?.maxSummaryLength ?? 100).replace(/\s+/g, ' ').trim());
231
- }
220
+ sections.push(truncateToOneLine(responseText, config?.maxSummaryLength ?? 100));
232
221
  }
233
222
 
234
223
  // Desktop notification
@@ -239,45 +228,6 @@ export default function (pi: ExtensionAPI) {
239
228
  });
240
229
  }
241
230
 
242
- /**
243
- * Summarize response text using the configured tiny model, falling back
244
- * to simple truncation if the LLM call fails or is disabled.
245
- */
246
- async function summarizeResponseWithFallback(
247
- ctx: any,
248
- responseText: string,
249
- config: TinyModelConfig,
250
- ): Promise<string> {
251
- if (!config.enabled) {
252
- return truncateToOneLine(responseText, config.maxSummaryLength);
253
- }
254
-
255
- try {
256
- const { provider, id } = parseModelString(config.model);
257
- const model = ctx.modelRegistry.find(provider, id);
258
- if (!model) {
259
- return truncateToOneLine(responseText, config.maxSummaryLength);
260
- }
261
-
262
- const { apiKey, headers } =
263
- await ctx.modelRegistry.getApiKeyAndHeaders(model);
264
-
265
- const summary = await summarizeResponseText(
266
- model,
267
- apiKey,
268
- headers,
269
- responseText,
270
- config.maxSummaryLength,
271
- );
272
-
273
- if (summary) return summary;
274
- } catch (err) {
275
- console.warn("[notify] LLM summary failed, falling back:", err);
276
- }
277
-
278
- return truncateToOneLine(responseText, config.maxSummaryLength);
279
- }
280
-
281
231
  /**
282
232
  * Truncate text to roughly `maxLen` characters on a single line,
283
233
  * breaking at a word boundary.
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
7
7
  - **Isolated context**: Each subagent runs in a separate `pi` process
8
8
  - **Streaming output**: See tool calls and progress as they happen
9
9
  - **Parallel streaming**: All parallel tasks stream updates simultaneously
10
- - **Markdown rendering**: Final output rendered with proper formatting (expanded view)
10
+ - **Plain-text output**: Final output wrapped to the terminal width (expanded view)
11
11
  - **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
12
12
  - **Abort support**: Ctrl+C propagates to kill subagent processes
13
13
 
@@ -125,7 +125,7 @@ pending `wait_for_agents` returns immediately with whatever is known so far.
125
125
  **Expanded view** (Ctrl+O):
126
126
  - Full task text
127
127
  - All tool calls with formatted arguments
128
- - Final output rendered as Markdown
128
+ - Final output rendered as width-wrapped plain text
129
129
  - Per-task usage (for chain/parallel)
130
130
 
131
131
  **Parallel mode streaming**:
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
6
7
  import * as path from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
- import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
9
+ import { parseFrontmatter } from "./frontmatter.ts";
9
10
 
10
11
  export type AgentScope = "user" | "project" | "both";
11
12
 
@@ -145,6 +146,21 @@ function isRenderedAgentsTree(agentsDir: string): boolean {
145
146
  return !entries.some((entry) => entry.isDirectory() && fs.existsSync(path.join(agentsDir, entry.name, "agent.md")));
146
147
  }
147
148
 
149
+ /**
150
+ * pi's user config directory, which holds pi's own `agents/` directory.
151
+ *
152
+ * Read here rather than through `getAgentDir()` from
153
+ * `@earendil-works/pi-coding-agent`: a value import of pi's runtime makes this
154
+ * extension fail to load outside the agent-cortex checkout
155
+ * (test/pi-extensions-load.test.mjs). Same contract — `PI_CODING_AGENT_DIR`
156
+ * wins when set, otherwise `~/.pi/agent`.
157
+ */
158
+ function getAgentDir(): string {
159
+ const override = process.env.PI_CODING_AGENT_DIR;
160
+ if (override) return path.resolve(override.replace(/^~(?=$|[\\/])/, os.homedir()));
161
+ return path.join(os.homedir(), ".pi", "agent");
162
+ }
163
+
148
164
  /**
149
165
  * Directories the "user" scope discovers agents from, in precedence order.
150
166
  *
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The local transcript components pi's renderers draw for the subagent tool.
3
+ *
4
+ * pi-tui's own components are unreachable from an installed extension, so these
5
+ * implement pi's `Component` contract directly. The contract that matters is the
6
+ * width: pi-tui reports a rendered line wider than the terminal as an error, so
7
+ * every line these return must fit the width they are given, with SGR colour
8
+ * sequences still intact.
9
+ */
10
+
11
+ import { describe, it } from "node:test";
12
+ import assert from "node:assert/strict";
13
+
14
+ import { Container, Spacer, Text } from "./components.ts";
15
+
16
+ const RESET = "\x1b[0m";
17
+ const GREEN = "\x1b[32m";
18
+
19
+ /** Visible column count, ignoring SGR sequences. */
20
+ function visibleWidth(line: string): number {
21
+ return line.replace(/\x1b\[[0-9;]*m/g, "").length;
22
+ }
23
+
24
+ /**
25
+ * Visible terminal-cell count, ignoring SGR sequences and counting a wide or
26
+ * astral character as two cells. `String.length` counts a wide character as one
27
+ * unit, so a width assertion built on it misses the overflow this guards.
28
+ */
29
+ function cellWidth(line: string): number {
30
+ return [...line.replace(/\x1b\[[0-9;]*m/g, "")].reduce((cells, character) => {
31
+ const codePoint = character.codePointAt(0) ?? 0;
32
+ const wide =
33
+ codePoint > 0xffff ||
34
+ (codePoint >= 0x1100 &&
35
+ (codePoint <= 0x115f ||
36
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
37
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
38
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
39
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
40
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
41
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6)));
42
+ return cells + (wide ? 2 : 1);
43
+ }, 0);
44
+ }
45
+
46
+ describe("Text", () => {
47
+ it("wraps a long line to the width it is given", () => {
48
+ const lines = new Text("alpha beta gamma delta").render(11);
49
+
50
+ assert.deepEqual(lines, ["alpha beta", "gamma delta"]);
51
+ assert.ok(lines.every((line) => visibleWidth(line) <= 11));
52
+ });
53
+
54
+ it("hard-wraps a word longer than the width instead of overflowing", () => {
55
+ const lines = new Text("abcdefghij").render(4);
56
+
57
+ assert.deepEqual(lines, ["abcd", "efgh", "ij"]);
58
+ });
59
+
60
+ it("keeps a colour sequence intact and carries it onto the wrapped line", () => {
61
+ const lines = new Text(`${GREEN}alpha beta gamma${RESET}`).render(11);
62
+
63
+ assert.equal(lines.length, 2);
64
+ for (const line of lines) assert.ok(visibleWidth(line) <= 11, `width of ${JSON.stringify(line)}`);
65
+ assert.ok(lines[0].endsWith(RESET), "a line cut inside a colour span closes it");
66
+ assert.ok(lines[1].startsWith(GREEN), "the continuation reopens the colour");
67
+ assert.equal(lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" "), "alpha beta gamma");
68
+ });
69
+
70
+ it("pads and separates lines with the padding it is given", () => {
71
+ const lines = new Text("body", 1, 1).render(10);
72
+
73
+ assert.deepEqual(lines, [" ", " body ", " "]);
74
+ });
75
+
76
+ it("keeps an empty line and renders blank text as one empty line", () => {
77
+ assert.deepEqual(new Text("a\n\nb").render(10), ["a", "", "b"]);
78
+ assert.deepEqual(new Text().render(10), [""]);
79
+ });
80
+
81
+ it("counts a wide character as two cells so it cannot overflow the width", () => {
82
+ // Each of these characters occupies two terminal cells but one string unit.
83
+ assert.deepEqual(new Text("日本語").render(4), ["日本", "語"]);
84
+
85
+ const lines = new Text("日本語のテキストは全角文字です。これは折り返しのテストです。").render(20);
86
+ assert.ok(lines.length > 1, "wide text must wrap");
87
+ for (const line of lines) assert.ok(cellWidth(line) <= 20, `width of ${JSON.stringify(line)}`);
88
+ });
89
+
90
+ it("counts an astral character as two cells", () => {
91
+ assert.deepEqual(new Text("🚀🚀🚀").render(4), ["🚀🚀", "🚀"]);
92
+ });
93
+ });
94
+
95
+ describe("Spacer", () => {
96
+ it("renders the blank lines it was asked for", () => {
97
+ assert.deepEqual(new Spacer(2).render(10), ["", ""]);
98
+ assert.deepEqual(new Spacer().render(10), [""]);
99
+ });
100
+ });
101
+
102
+ describe("Container", () => {
103
+ it("draws its children in order, each at the given width", () => {
104
+ const container = new Container();
105
+ container.addChild(new Text("alpha beta"));
106
+ container.addChild(new Spacer(1));
107
+ container.addChild(new Text("gamma"));
108
+
109
+ assert.deepEqual(container.render(5), ["alpha", "beta", "", "gamma"]);
110
+ });
111
+ });