@letta-ai/image-understanding 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/LICENSE +21 -0
- package/MOD.md +84 -0
- package/README.md +204 -0
- package/mods/index.mjs +452 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Letta, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/MOD.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "@letta-ai/image-understanding"
|
|
3
|
+
description: "Image-understanding tool, slash commands, and optional auto-captioning for text-only agents."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Image understanding mod semantics
|
|
7
|
+
|
|
8
|
+
## When to use
|
|
9
|
+
|
|
10
|
+
Use this package when the current model cannot inspect images directly but needs to understand a screenshot, diagram, photo, UI error, OCR text, or accessibility context.
|
|
11
|
+
|
|
12
|
+
The package does not make the main model natively multimodal. It sends the image to a separate configured vision backend and returns text.
|
|
13
|
+
|
|
14
|
+
## Capabilities
|
|
15
|
+
|
|
16
|
+
This package registers:
|
|
17
|
+
|
|
18
|
+
- Tool: `image_understand`
|
|
19
|
+
- Commands:
|
|
20
|
+
- `/image-understand`
|
|
21
|
+
- `/image-understanding-status`
|
|
22
|
+
- Optional `turn_start` auto-captioning when `IMAGE_UNDERSTANDING_AUTO_CAPTION=1`
|
|
23
|
+
|
|
24
|
+
## Providers
|
|
25
|
+
|
|
26
|
+
Supported providers:
|
|
27
|
+
|
|
28
|
+
- `openai-compatible` (default), using chat completions with image input
|
|
29
|
+
- `ollama`, using `/api/generate` with `images`
|
|
30
|
+
|
|
31
|
+
Configuration is controlled by environment variables, especially:
|
|
32
|
+
|
|
33
|
+
- `IMAGE_UNDERSTANDING_PROVIDER`
|
|
34
|
+
- `IMAGE_UNDERSTANDING_API_KEY` or `OPENAI_API_KEY`
|
|
35
|
+
- `IMAGE_UNDERSTANDING_MODEL`
|
|
36
|
+
- `IMAGE_UNDERSTANDING_BASE_URL`
|
|
37
|
+
- `IMAGE_UNDERSTANDING_ALLOW_CLOUD`
|
|
38
|
+
- `IMAGE_UNDERSTANDING_ALLOW_URLS`
|
|
39
|
+
- `IMAGE_UNDERSTANDING_REQUIRE_LOCAL`
|
|
40
|
+
- `IMAGE_UNDERSTANDING_AUTO_CAPTION`
|
|
41
|
+
- `IMAGE_UNDERSTANDING_AUTO_MODE`
|
|
42
|
+
|
|
43
|
+
## Tool behavior
|
|
44
|
+
|
|
45
|
+
`image_understand` supports:
|
|
46
|
+
|
|
47
|
+
- `action: "status"` to inspect provider configuration
|
|
48
|
+
- `path_or_url` for local image paths, workspace-relative paths, `~/` paths, or HTTP(S) image URLs
|
|
49
|
+
- `question` for targeted image questions
|
|
50
|
+
- `mode` for built-in prompts: `describe`, `ocr`, `ui_debug`, `diagram`, `accessibility`
|
|
51
|
+
- `detail` for OpenAI-compatible detail preference (`low`, `high`, `auto`)
|
|
52
|
+
|
|
53
|
+
Agent-initiated tool calls require approval because image bytes may be read from local disk or sent to a provider.
|
|
54
|
+
|
|
55
|
+
## Auto-captioning behavior
|
|
56
|
+
|
|
57
|
+
Auto-captioning is opt-in. When enabled, the mod listens for `turn_start`, finds image content parts or markdown image links in user messages, runs image understanding, and appends text descriptions to the user turn before the main model sees it.
|
|
58
|
+
|
|
59
|
+
For sensitive images, prefer:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
IMAGE_UNDERSTANDING_PROVIDER=ollama
|
|
63
|
+
IMAGE_UNDERSTANDING_ALLOW_CLOUD=0
|
|
64
|
+
IMAGE_UNDERSTANDING_REQUIRE_LOCAL=1
|
|
65
|
+
IMAGE_UNDERSTANDING_AUTO_CAPTION=1
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Safety
|
|
69
|
+
|
|
70
|
+
- Slash commands are direct user actions and run immediately.
|
|
71
|
+
- Auto-captioning runs automatically once enabled.
|
|
72
|
+
- `IMAGE_UNDERSTANDING_ALLOW_CLOUD=0` blocks non-local providers.
|
|
73
|
+
- `IMAGE_UNDERSTANDING_ALLOW_URLS=0` blocks remote image fetching.
|
|
74
|
+
- `IMAGE_UNDERSTANDING_REQUIRE_LOCAL=1` requires the Ollama provider.
|
|
75
|
+
- Local and fetched images over 20 MB are rejected.
|
|
76
|
+
- Supported local extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`.
|
|
77
|
+
|
|
78
|
+
## Adaptation notes for agents
|
|
79
|
+
|
|
80
|
+
- Use `/image-understanding-status` or tool `action: "status"` before debugging provider setup.
|
|
81
|
+
- Prefer `mode: "ui_debug"` for screenshots and error states.
|
|
82
|
+
- Prefer `mode: "ocr"` when the goal is text extraction.
|
|
83
|
+
- Prefer `mode: "diagram"` for architecture or technical diagrams.
|
|
84
|
+
- Do not enable auto-captioning casually; it is intentionally opt-in.
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Image Understanding
|
|
2
|
+
|
|
3
|
+
A Letta Code mod package that gives text-only/non-vision agents image understanding by routing images through a separate vision backend and returning text the main model can reason over.
|
|
4
|
+
|
|
5
|
+
This does not make the main model natively multimodal. It adds a trusted bridge:
|
|
6
|
+
|
|
7
|
+
1. The user or agent provides an image path or URL.
|
|
8
|
+
2. The mod sends that image to a configured vision backend.
|
|
9
|
+
3. The backend returns a text description, OCR extraction, UI-debug analysis, diagram explanation, or accessibility description.
|
|
10
|
+
4. The text-only agent uses that returned text like any other context.
|
|
11
|
+
|
|
12
|
+
Original source: <https://tangled.org/cameron.stream/image-understanding>
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
letta install npm:@letta-ai/image-understanding
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Run `/reload` in active sessions after installing.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- Agent tool: `image_understand`
|
|
25
|
+
- Slash commands:
|
|
26
|
+
- `/image-understand`
|
|
27
|
+
- `/image-understanding-status`
|
|
28
|
+
- Optional `turn_start` auto-captioning for image-bearing user turns
|
|
29
|
+
- Prompt modes:
|
|
30
|
+
- `describe`
|
|
31
|
+
- `ocr`
|
|
32
|
+
- `ui_debug`
|
|
33
|
+
- `diagram`
|
|
34
|
+
- `accessibility`
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
### OpenAI-compatible provider
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export OPENAI_API_KEY=...
|
|
42
|
+
# optional
|
|
43
|
+
export IMAGE_UNDERSTANDING_PROVIDER=openai-compatible
|
|
44
|
+
export IMAGE_UNDERSTANDING_MODEL=gpt-4o-mini
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then reload and test:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
/reload
|
|
51
|
+
/image-understanding-status
|
|
52
|
+
/image-understand ~/Desktop/screenshot.png what error is shown?
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Local Ollama provider
|
|
56
|
+
|
|
57
|
+
Use this when you want image bytes to stay local.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ollama pull llava:latest
|
|
61
|
+
export IMAGE_UNDERSTANDING_PROVIDER=ollama
|
|
62
|
+
export IMAGE_UNDERSTANDING_MODEL=llava:latest
|
|
63
|
+
export IMAGE_UNDERSTANDING_BASE_URL=http://localhost:11434
|
|
64
|
+
export IMAGE_UNDERSTANDING_ALLOW_CLOUD=0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then reload and test:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
/reload
|
|
71
|
+
/image-understanding-status
|
|
72
|
+
/image-understand ~/Desktop/screenshot.png summarize this screenshot
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Any Ollama model that supports image input should work. Other possible models include `llama3.2-vision` or Qwen/VL variants if they are available in your Ollama installation.
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
| Variable | Default | Description |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `IMAGE_UNDERSTANDING_PROVIDER` | `openai-compatible` | Provider backend. Supported: `openai-compatible`, `ollama`. Alias: `openai`. |
|
|
82
|
+
| `IMAGE_UNDERSTANDING_API_KEY` | unset | API key for OpenAI-compatible backends. Overrides `OPENAI_API_KEY`. |
|
|
83
|
+
| `OPENAI_API_KEY` | unset | Fallback API key for OpenAI-compatible backends. |
|
|
84
|
+
| `IMAGE_UNDERSTANDING_MODEL` | `gpt-4o-mini` or `llava:latest` | Vision model name. Default depends on provider. |
|
|
85
|
+
| `IMAGE_UNDERSTANDING_BASE_URL` | OpenAI or Ollama URL | Base URL. OpenAI-compatible default: `https://api.openai.com/v1`; Ollama default: `http://localhost:11434`. |
|
|
86
|
+
| `IMAGE_UNDERSTANDING_MAX_TOKENS` | `1200` | Max tokens for OpenAI-compatible responses. |
|
|
87
|
+
| `IMAGE_UNDERSTANDING_ALLOW_CLOUD` | `1` | Set `0` to block non-local providers. |
|
|
88
|
+
| `IMAGE_UNDERSTANDING_ALLOW_URLS` | `1` | Set `0` to block fetching remote image URLs. |
|
|
89
|
+
| `IMAGE_UNDERSTANDING_REQUIRE_LOCAL` | `0` | Set `1` to require local provider use. Currently this requires `provider=ollama`. |
|
|
90
|
+
| `IMAGE_UNDERSTANDING_AUTO_CAPTION` | `0` | Set `1` to enable automatic image caption injection on `turn_start`. |
|
|
91
|
+
| `IMAGE_UNDERSTANDING_AUTO_MODE` | `describe` | Mode used for auto-captioning. Supports `describe`, `ocr`, `ui_debug`, `diagram`, `accessibility`. |
|
|
92
|
+
|
|
93
|
+
## Tool usage
|
|
94
|
+
|
|
95
|
+
Status check:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{ "action": "status" }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
General image description:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"path_or_url": "~/Desktop/screenshot.png"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Targeted question:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"path_or_url": "~/Desktop/screenshot.png",
|
|
114
|
+
"question": "What error is shown in this screenshot?"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Use a built-in mode:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"path_or_url": "~/Desktop/screenshot.png",
|
|
123
|
+
"mode": "ui_debug"
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Slash commands
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
/image-understanding-status
|
|
131
|
+
/image-understand ~/Desktop/screenshot.png what error is shown?
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Use quotes for paths containing spaces:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
/image-understand "~/Desktop/error screenshot.png" summarize the UI state
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Auto-captioning
|
|
141
|
+
|
|
142
|
+
Auto-captioning is off by default. Enable it only when you want the mod to automatically inspect images before the main model sees the user turn.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
export IMAGE_UNDERSTANDING_AUTO_CAPTION=1
|
|
146
|
+
export IMAGE_UNDERSTANDING_AUTO_MODE=ui_debug
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
For private local-only auto-captioning:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
export IMAGE_UNDERSTANDING_PROVIDER=ollama
|
|
153
|
+
export IMAGE_UNDERSTANDING_ALLOW_CLOUD=0
|
|
154
|
+
export IMAGE_UNDERSTANDING_REQUIRE_LOCAL=1
|
|
155
|
+
export IMAGE_UNDERSTANDING_AUTO_CAPTION=1
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Privacy and security
|
|
159
|
+
|
|
160
|
+
This is trusted local code. It can read local image files you ask it to process and can send image bytes to the configured backend.
|
|
161
|
+
|
|
162
|
+
Important behavior:
|
|
163
|
+
|
|
164
|
+
- Agent-initiated `image_understand` tool calls require approval.
|
|
165
|
+
- Slash commands are direct user actions and run immediately.
|
|
166
|
+
- Auto-captioning is opt-in and runs automatically once enabled.
|
|
167
|
+
- `IMAGE_UNDERSTANDING_ALLOW_CLOUD=0` blocks non-local providers.
|
|
168
|
+
- `IMAGE_UNDERSTANDING_ALLOW_URLS=0` blocks fetching remote image URLs.
|
|
169
|
+
- `IMAGE_UNDERSTANDING_REQUIRE_LOCAL=1` requires local provider use.
|
|
170
|
+
|
|
171
|
+
For sensitive screenshots, prefer Ollama/local provider mode.
|
|
172
|
+
|
|
173
|
+
## Supported inputs
|
|
174
|
+
|
|
175
|
+
Local file extensions:
|
|
176
|
+
|
|
177
|
+
- `.png`
|
|
178
|
+
- `.jpg`
|
|
179
|
+
- `.jpeg`
|
|
180
|
+
- `.webp`
|
|
181
|
+
- `.gif`
|
|
182
|
+
|
|
183
|
+
Remote inputs:
|
|
184
|
+
|
|
185
|
+
- `http://...`
|
|
186
|
+
- `https://...`
|
|
187
|
+
|
|
188
|
+
Limits:
|
|
189
|
+
|
|
190
|
+
- Local and fetched images over 20 MB are rejected.
|
|
191
|
+
- URL responses must have an `image/*` content type.
|
|
192
|
+
- Unsupported local extensions are rejected unless the image is provided via HTTP(S).
|
|
193
|
+
|
|
194
|
+
## Safety
|
|
195
|
+
|
|
196
|
+
If a mod breaks startup or command handling, recover with:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
letta --no-mods
|
|
200
|
+
# or
|
|
201
|
+
LETTA_DISABLE_MODS=1 letta
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
See [`MOD.md`](./MOD.md) for the agent-facing behavioral contract.
|
package/mods/index.mjs
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PROVIDER = "openai-compatible";
|
|
6
|
+
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
|
|
7
|
+
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
8
|
+
const DEFAULT_OLLAMA_MODEL = "llava:latest";
|
|
9
|
+
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
|
|
10
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
const MIME_BY_EXT = new Map([
|
|
13
|
+
[".png", "image/png"],
|
|
14
|
+
[".jpg", "image/jpeg"],
|
|
15
|
+
[".jpeg", "image/jpeg"],
|
|
16
|
+
[".webp", "image/webp"],
|
|
17
|
+
[".gif", "image/gif"],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const MODE_PROMPTS = {
|
|
21
|
+
describe:
|
|
22
|
+
"Describe this image clearly. Include important visual details, context, visible objects, people, UI state, errors, diagrams, and any visible text.",
|
|
23
|
+
ocr:
|
|
24
|
+
"Extract all visible text from this image. Preserve line breaks and reading order where possible. If text is unclear, mark uncertain words with [?].",
|
|
25
|
+
ui_debug:
|
|
26
|
+
"Analyze this screenshot for UI/debugging. Extract visible text, identify errors or broken states, describe relevant controls/layout, and suggest likely next debugging steps.",
|
|
27
|
+
diagram:
|
|
28
|
+
"Explain this diagram or technical visual. Identify nodes, arrows, labels, structure, relationships, and the main takeaway.",
|
|
29
|
+
accessibility:
|
|
30
|
+
"Write an accessibility-focused description of this image for someone who cannot see it. Include layout, salient visual details, and visible text.",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function envBool(name, defaultValue) {
|
|
34
|
+
const raw = process.env[name];
|
|
35
|
+
if (raw === undefined || raw === "") return defaultValue;
|
|
36
|
+
return !["0", "false", "no", "off"].includes(String(raw).toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeProvider(value) {
|
|
40
|
+
const provider = String(value || DEFAULT_PROVIDER).toLowerCase();
|
|
41
|
+
if (["openai", "openai-compatible", "openai_compatible"].includes(provider)) return "openai-compatible";
|
|
42
|
+
if (provider === "ollama") return "ollama";
|
|
43
|
+
return provider;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getConfig() {
|
|
47
|
+
const provider = normalizeProvider(process.env.IMAGE_UNDERSTANDING_PROVIDER);
|
|
48
|
+
const openaiApiKey = process.env.IMAGE_UNDERSTANDING_API_KEY || process.env.OPENAI_API_KEY || "";
|
|
49
|
+
return {
|
|
50
|
+
provider,
|
|
51
|
+
apiKey: openaiApiKey,
|
|
52
|
+
model:
|
|
53
|
+
process.env.IMAGE_UNDERSTANDING_MODEL ||
|
|
54
|
+
(provider === "ollama" ? DEFAULT_OLLAMA_MODEL : DEFAULT_OPENAI_MODEL),
|
|
55
|
+
baseUrl: (
|
|
56
|
+
process.env.IMAGE_UNDERSTANDING_BASE_URL ||
|
|
57
|
+
(provider === "ollama" ? DEFAULT_OLLAMA_BASE_URL : DEFAULT_OPENAI_BASE_URL)
|
|
58
|
+
).replace(/\/$/, ""),
|
|
59
|
+
maxTokens: Number.parseInt(process.env.IMAGE_UNDERSTANDING_MAX_TOKENS || "1200", 10),
|
|
60
|
+
allowCloud: envBool("IMAGE_UNDERSTANDING_ALLOW_CLOUD", true),
|
|
61
|
+
allowUrls: envBool("IMAGE_UNDERSTANDING_ALLOW_URLS", true),
|
|
62
|
+
requireLocal: envBool("IMAGE_UNDERSTANDING_REQUIRE_LOCAL", false),
|
|
63
|
+
autoCaption: envBool("IMAGE_UNDERSTANDING_AUTO_CAPTION", false),
|
|
64
|
+
autoMode: process.env.IMAGE_UNDERSTANDING_AUTO_MODE || "describe",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isHttpUrl(value) {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(value);
|
|
71
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveLocalPath(input, cwd) {
|
|
78
|
+
const expanded = input.startsWith("~/")
|
|
79
|
+
? path.join(process.env.HOME || "", input.slice(2))
|
|
80
|
+
: input;
|
|
81
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(cwd || process.cwd(), expanded);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function promptFor({ question, mode }) {
|
|
85
|
+
const trimmed = typeof question === "string" ? question.trim() : "";
|
|
86
|
+
if (trimmed) return trimmed;
|
|
87
|
+
return MODE_PROMPTS[mode] || MODE_PROMPTS.describe;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function localImage(pathOrUrl, cwd) {
|
|
91
|
+
const resolved = resolveLocalPath(pathOrUrl, cwd);
|
|
92
|
+
const stat = statSync(resolved);
|
|
93
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${resolved}`);
|
|
94
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
95
|
+
throw new Error(`Image is too large (${stat.size} bytes). Limit is ${MAX_IMAGE_BYTES} bytes.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
99
|
+
const mime = MIME_BY_EXT.get(ext);
|
|
100
|
+
if (!mime) {
|
|
101
|
+
throw new Error(`Unsupported image extension "${ext}". Use png, jpg, jpeg, webp, gif, or an http(s) URL.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = await readFile(resolved);
|
|
105
|
+
return { bytes: data, base64: data.toString("base64"), mime, source: resolved, isUrl: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function urlImage(pathOrUrl, signal) {
|
|
109
|
+
const response = await fetch(pathOrUrl, { signal });
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(`Failed to fetch image URL: HTTP ${response.status} ${response.statusText}`);
|
|
112
|
+
}
|
|
113
|
+
const contentLength = Number.parseInt(response.headers.get("content-length") || "0", 10);
|
|
114
|
+
if (contentLength > MAX_IMAGE_BYTES) {
|
|
115
|
+
throw new Error(`Image URL is too large (${contentLength} bytes). Limit is ${MAX_IMAGE_BYTES} bytes.`);
|
|
116
|
+
}
|
|
117
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
118
|
+
if (arrayBuffer.byteLength > MAX_IMAGE_BYTES) {
|
|
119
|
+
throw new Error(`Image URL is too large (${arrayBuffer.byteLength} bytes). Limit is ${MAX_IMAGE_BYTES} bytes.`);
|
|
120
|
+
}
|
|
121
|
+
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
|
|
122
|
+
if (!contentType.startsWith("image/")) {
|
|
123
|
+
throw new Error(`URL did not return an image content-type: ${contentType}`);
|
|
124
|
+
}
|
|
125
|
+
const bytes = Buffer.from(arrayBuffer);
|
|
126
|
+
return { bytes, base64: bytes.toString("base64"), mime: contentType, source: pathOrUrl, isUrl: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function loadImage(pathOrUrl, ctx) {
|
|
130
|
+
return isHttpUrl(pathOrUrl) ? await urlImage(pathOrUrl, ctx.signal) : await localImage(pathOrUrl, ctx.cwd);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function enforceSafety({ pathOrUrl, config }) {
|
|
134
|
+
if (config.requireLocal && config.provider !== "ollama") {
|
|
135
|
+
return "IMAGE_UNDERSTANDING_REQUIRE_LOCAL=1 requires IMAGE_UNDERSTANDING_PROVIDER=ollama.";
|
|
136
|
+
}
|
|
137
|
+
if (!config.allowCloud && config.provider !== "ollama") {
|
|
138
|
+
return "IMAGE_UNDERSTANDING_ALLOW_CLOUD=0 blocks non-local vision providers. Set IMAGE_UNDERSTANDING_PROVIDER=ollama or allow cloud use.";
|
|
139
|
+
}
|
|
140
|
+
if (!config.allowUrls && isHttpUrl(pathOrUrl)) {
|
|
141
|
+
return "IMAGE_UNDERSTANDING_ALLOW_URLS=0 blocks fetching image URLs. Use a local file path or allow URLs.";
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseJson(text) {
|
|
147
|
+
try {
|
|
148
|
+
return text ? JSON.parse(text) : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return text;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function errorContent(message, config) {
|
|
155
|
+
const setup = config.provider === "ollama"
|
|
156
|
+
? `Ollama setup example: IMAGE_UNDERSTANDING_PROVIDER=ollama IMAGE_UNDERSTANDING_MODEL=${DEFAULT_OLLAMA_MODEL} IMAGE_UNDERSTANDING_BASE_URL=${DEFAULT_OLLAMA_BASE_URL}`
|
|
157
|
+
: "OpenAI-compatible setup example: set OPENAI_API_KEY or IMAGE_UNDERSTANDING_API_KEY, optionally IMAGE_UNDERSTANDING_MODEL and IMAGE_UNDERSTANDING_BASE_URL.";
|
|
158
|
+
return `${message}\n${setup}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function askOpenAiCompatible({ image, prompt, detail }, config, ctx) {
|
|
162
|
+
if (!config.apiKey) {
|
|
163
|
+
return { status: "error", content: errorContent("Missing API key for openai-compatible image understanding.", config) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const imageUrl = { url: `data:${image.mime};base64,${image.base64}` };
|
|
167
|
+
if (detail) imageUrl.detail = detail;
|
|
168
|
+
|
|
169
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
model: config.model,
|
|
177
|
+
messages: [
|
|
178
|
+
{
|
|
179
|
+
role: "user",
|
|
180
|
+
content: [
|
|
181
|
+
{ type: "text", text: prompt },
|
|
182
|
+
{ type: "image_url", image_url: imageUrl },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
max_tokens: Number.isFinite(config.maxTokens) ? config.maxTokens : 1200,
|
|
187
|
+
}),
|
|
188
|
+
signal: ctx.signal,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const body = parseJson(await response.text());
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const rendered = typeof body === "string" ? body : JSON.stringify(body);
|
|
194
|
+
const hint = response.status === 400
|
|
195
|
+
? " The configured endpoint/model may not support image input. Use a vision-capable model or provider=ollama with a vision model."
|
|
196
|
+
: "";
|
|
197
|
+
return { status: "error", content: errorContent(`Vision request failed: HTTP ${response.status} ${response.statusText}: ${rendered}.${hint}`, config) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const answer = body?.choices?.[0]?.message?.content;
|
|
201
|
+
if (!answer) return { status: "error", content: `Vision response had no answer: ${JSON.stringify(body)}` };
|
|
202
|
+
return answer;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function askOllama({ image, prompt }, config, ctx) {
|
|
206
|
+
const response = await fetch(`${config.baseUrl}/api/generate`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
model: config.model,
|
|
211
|
+
prompt,
|
|
212
|
+
images: [image.base64],
|
|
213
|
+
stream: false,
|
|
214
|
+
}),
|
|
215
|
+
signal: ctx.signal,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const body = parseJson(await response.text());
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const rendered = typeof body === "string" ? body : JSON.stringify(body);
|
|
221
|
+
const hint = response.status === 404
|
|
222
|
+
? ` Is Ollama running and is the model pulled? Try: ollama pull ${config.model}`
|
|
223
|
+
: "";
|
|
224
|
+
return { status: "error", content: errorContent(`Ollama vision request failed: HTTP ${response.status} ${response.statusText}: ${rendered}.${hint}`, config) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const answer = body?.response;
|
|
228
|
+
if (!answer) return { status: "error", content: `Ollama response had no answer: ${JSON.stringify(body)}` };
|
|
229
|
+
return answer;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function askVision({ pathOrUrl, question, detail, mode }, ctx) {
|
|
233
|
+
const config = getConfig();
|
|
234
|
+
if (!["openai-compatible", "ollama"].includes(config.provider)) {
|
|
235
|
+
return { status: "error", content: `Unsupported IMAGE_UNDERSTANDING_PROVIDER=${config.provider}. Supported providers: openai-compatible, ollama.` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const safetyError = enforceSafety({ pathOrUrl, config });
|
|
239
|
+
if (safetyError) return { status: "error", content: safetyError };
|
|
240
|
+
|
|
241
|
+
const image = await loadImage(pathOrUrl, ctx);
|
|
242
|
+
const prompt = promptFor({ question, mode });
|
|
243
|
+
|
|
244
|
+
if (config.provider === "ollama") {
|
|
245
|
+
return await askOllama({ image, prompt }, config, ctx);
|
|
246
|
+
}
|
|
247
|
+
return await askOpenAiCompatible({ image, prompt, detail }, config, ctx);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function providerStatus() {
|
|
251
|
+
const config = getConfig();
|
|
252
|
+
const lines = [
|
|
253
|
+
`provider: ${config.provider}`,
|
|
254
|
+
`model: ${config.model}`,
|
|
255
|
+
`base_url: ${config.baseUrl}`,
|
|
256
|
+
`api_key: ${config.provider === "ollama" ? "not required" : (config.apiKey ? "present" : "missing")}`,
|
|
257
|
+
`allow_cloud: ${config.allowCloud ? "yes" : "no"}`,
|
|
258
|
+
`allow_urls: ${config.allowUrls ? "yes" : "no"}`,
|
|
259
|
+
`require_local: ${config.requireLocal ? "yes" : "no"}`,
|
|
260
|
+
`auto_caption: ${config.autoCaption ? "yes" : "no"}`,
|
|
261
|
+
`auto_mode: ${config.autoMode}`,
|
|
262
|
+
`supported_providers: openai-compatible, ollama`,
|
|
263
|
+
`modes: ${Object.keys(MODE_PROMPTS).join(", ")}`,
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
if (config.provider === "ollama") {
|
|
267
|
+
try {
|
|
268
|
+
const response = await fetch(`${config.baseUrl}/api/tags`, { signal: AbortSignal.timeout(3_000) });
|
|
269
|
+
lines.push(`ollama_reachable: ${response.ok ? "yes" : `no (${response.status})`}`);
|
|
270
|
+
if (response.ok) {
|
|
271
|
+
const body = await response.json();
|
|
272
|
+
const models = Array.isArray(body.models) ? body.models.map((m) => m.name).slice(0, 20) : [];
|
|
273
|
+
lines.push(`ollama_models: ${models.length ? models.join(", ") : "none reported"}`);
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
lines.push(`ollama_reachable: no (${error instanceof Error ? error.message : String(error)})`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return lines.join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function imageRefFromPart(part) {
|
|
284
|
+
if (!part || typeof part !== "object") return null;
|
|
285
|
+
if (typeof part.path === "string" && part.path) return part.path;
|
|
286
|
+
if (typeof part.file_path === "string" && part.file_path) return part.file_path;
|
|
287
|
+
if (typeof part.url === "string" && isHttpUrl(part.url)) return part.url;
|
|
288
|
+
if (typeof part.image_url === "string" && isHttpUrl(part.image_url)) return part.image_url;
|
|
289
|
+
if (part.image_url && typeof part.image_url === "object" && typeof part.image_url.url === "string") return part.image_url.url;
|
|
290
|
+
if (part.source && typeof part.source === "object") {
|
|
291
|
+
if (typeof part.source.path === "string") return part.source.path;
|
|
292
|
+
if (typeof part.source.url === "string") return part.source.url;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractImageRefsFromContent(content) {
|
|
298
|
+
const refs = [];
|
|
299
|
+
if (typeof content === "string") {
|
|
300
|
+
const markdownImage = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
301
|
+
for (const match of content.matchAll(markdownImage)) {
|
|
302
|
+
const ref = match[1]?.trim();
|
|
303
|
+
if (ref) refs.push(ref);
|
|
304
|
+
}
|
|
305
|
+
return refs;
|
|
306
|
+
}
|
|
307
|
+
if (!Array.isArray(content)) return refs;
|
|
308
|
+
for (const part of content) {
|
|
309
|
+
const type = String(part?.type || "").toLowerCase();
|
|
310
|
+
const looksImage = type.includes("image") || Boolean(part?.image_url);
|
|
311
|
+
if (!looksImage) continue;
|
|
312
|
+
const ref = imageRefFromPart(part);
|
|
313
|
+
if (ref) refs.push(ref);
|
|
314
|
+
}
|
|
315
|
+
return refs;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function appendTextContent(content, text) {
|
|
319
|
+
if (typeof content === "string") return `${content}\n\n${text}`;
|
|
320
|
+
if (Array.isArray(content)) return [...content, { type: "text", text }];
|
|
321
|
+
return text;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function autoCaptionTurn(event, ctx) {
|
|
325
|
+
const config = getConfig();
|
|
326
|
+
if (!config.autoCaption) return;
|
|
327
|
+
const input = Array.isArray(event.input) ? event.input : [];
|
|
328
|
+
const descriptions = [];
|
|
329
|
+
|
|
330
|
+
for (const item of input) {
|
|
331
|
+
if (!item || item.type === "approval" || item.role !== "user") continue;
|
|
332
|
+
const refs = extractImageRefsFromContent(item.content);
|
|
333
|
+
for (const ref of refs.slice(0, 4)) {
|
|
334
|
+
try {
|
|
335
|
+
const result = await askVision({ pathOrUrl: ref, mode: config.autoMode }, ctx);
|
|
336
|
+
const text = typeof result === "string" ? result : result.content || JSON.stringify(result);
|
|
337
|
+
descriptions.push(`Image ${descriptions.length + 1} (${ref}):\n${text}`);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
descriptions.push(`Image ${descriptions.length + 1} (${ref}): auto-caption failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (descriptions.length === 0) return;
|
|
345
|
+
const note = `[Auto image understanding (${config.provider}/${config.model})]\n${descriptions.join("\n\n")}`;
|
|
346
|
+
event.input = input.map((item) => {
|
|
347
|
+
if (!item || item.type === "approval" || item.role !== "user") return item;
|
|
348
|
+
const refs = extractImageRefsFromContent(item.content);
|
|
349
|
+
if (refs.length === 0) return item;
|
|
350
|
+
return { ...item, content: appendTextContent(item.content, note) };
|
|
351
|
+
});
|
|
352
|
+
return { input: event.input };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseCommandArgs(args) {
|
|
356
|
+
const raw = String(args || "").trim();
|
|
357
|
+
if (!raw) return { pathOrUrl: "", question: "" };
|
|
358
|
+
if (raw.startsWith('"') || raw.startsWith("'")) {
|
|
359
|
+
const quote = raw[0];
|
|
360
|
+
const end = raw.indexOf(quote, 1);
|
|
361
|
+
if (end > 0) {
|
|
362
|
+
return { pathOrUrl: raw.slice(1, end), question: raw.slice(end + 1).trim() };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const [pathOrUrl, ...rest] = raw.split(/\s+/);
|
|
366
|
+
return { pathOrUrl, question: rest.join(" ").trim() };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export default function activate(letta) {
|
|
370
|
+
const disposers = [];
|
|
371
|
+
|
|
372
|
+
if (letta.capabilities.tools) {
|
|
373
|
+
disposers.push(letta.tools.register({
|
|
374
|
+
name: "image_understand",
|
|
375
|
+
description: "Use a vision model to answer questions about a local image path or image URL when the current model cannot inspect images directly. Supports OpenAI-compatible vision and local Ollama vision backends. Useful for screenshots, UI errors, diagrams, photos, OCR, and visual debugging.",
|
|
376
|
+
parameters: {
|
|
377
|
+
type: "object",
|
|
378
|
+
properties: {
|
|
379
|
+
action: {
|
|
380
|
+
type: "string",
|
|
381
|
+
enum: ["understand", "status"],
|
|
382
|
+
description: "Use status to inspect provider configuration. Defaults to understand.",
|
|
383
|
+
},
|
|
384
|
+
path_or_url: {
|
|
385
|
+
type: "string",
|
|
386
|
+
description: "Local image path, path relative to the current workspace, ~/ path, or http(s) image URL. Required for action=understand.",
|
|
387
|
+
},
|
|
388
|
+
question: {
|
|
389
|
+
type: "string",
|
|
390
|
+
description: "Optional specific question to ask about the image. If omitted, the selected mode prompt is used.",
|
|
391
|
+
},
|
|
392
|
+
mode: {
|
|
393
|
+
type: "string",
|
|
394
|
+
enum: ["describe", "ocr", "ui_debug", "diagram", "accessibility"],
|
|
395
|
+
description: "Prompt mode to use when question is omitted. Defaults to describe.",
|
|
396
|
+
},
|
|
397
|
+
detail: {
|
|
398
|
+
type: "string",
|
|
399
|
+
enum: ["low", "high", "auto"],
|
|
400
|
+
description: "Optional OpenAI image detail preference. Ignored by Ollama.",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
additionalProperties: false,
|
|
404
|
+
},
|
|
405
|
+
requiresApproval: true,
|
|
406
|
+
parallelSafe: true,
|
|
407
|
+
async run(ctx) {
|
|
408
|
+
const action = String(ctx.args.action || "understand");
|
|
409
|
+
if (action === "status") return await providerStatus();
|
|
410
|
+
const pathOrUrl = String(ctx.args.path_or_url || "").trim();
|
|
411
|
+
if (!pathOrUrl) return { status: "error", content: "path_or_url is required for image understanding. Use action=status to inspect configuration." };
|
|
412
|
+
const question = typeof ctx.args.question === "string" ? ctx.args.question : "";
|
|
413
|
+
const detail = typeof ctx.args.detail === "string" ? ctx.args.detail : undefined;
|
|
414
|
+
const mode = typeof ctx.args.mode === "string" ? ctx.args.mode : "describe";
|
|
415
|
+
return await askVision({ pathOrUrl, question, detail, mode }, ctx);
|
|
416
|
+
},
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (letta.capabilities.events?.turns) {
|
|
421
|
+
disposers.push(letta.events.on("turn_start", autoCaptionTurn));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (letta.capabilities.commands) {
|
|
425
|
+
disposers.push(letta.commands.register({
|
|
426
|
+
id: "image-understanding-status",
|
|
427
|
+
description: "Show image-understanding provider configuration and diagnostics",
|
|
428
|
+
async run() {
|
|
429
|
+
return { type: "output", output: await providerStatus() };
|
|
430
|
+
},
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
disposers.push(letta.commands.register({
|
|
434
|
+
id: "image-understand",
|
|
435
|
+
description: "Inspect an image with the configured vision backend",
|
|
436
|
+
args: "<path-or-url> [question]",
|
|
437
|
+
async run(ctx) {
|
|
438
|
+
const { pathOrUrl, question } = parseCommandArgs(ctx.args);
|
|
439
|
+
if (!pathOrUrl) {
|
|
440
|
+
return { type: "output", output: "Usage: /image-understand <path-or-url> [question]" };
|
|
441
|
+
}
|
|
442
|
+
const result = await askVision({ pathOrUrl, question, mode: "describe" }, ctx);
|
|
443
|
+
const output = typeof result === "string" ? result : result.content || JSON.stringify(result, null, 2);
|
|
444
|
+
return { type: "output", output };
|
|
445
|
+
},
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return () => {
|
|
450
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
451
|
+
};
|
|
452
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@letta-ai/image-understanding",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Letta Code mod package that adds image understanding for text-only agents using a separate vision backend.",
|
|
5
|
+
"author": "just-cameron",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"letta-package",
|
|
10
|
+
"letta-mod",
|
|
11
|
+
"letta-code",
|
|
12
|
+
"image-understanding",
|
|
13
|
+
"vision",
|
|
14
|
+
"ollama"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/letta-ai/mods.git",
|
|
19
|
+
"directory": "packages/image-understanding"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"README.md",
|
|
23
|
+
"MOD.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"mods"
|
|
26
|
+
],
|
|
27
|
+
"letta": {
|
|
28
|
+
"manifestVersion": 1,
|
|
29
|
+
"mods": [
|
|
30
|
+
"./mods/index.mjs"
|
|
31
|
+
],
|
|
32
|
+
"capabilities": [
|
|
33
|
+
"tools",
|
|
34
|
+
"commands",
|
|
35
|
+
"events.turns"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"lettaCodeCli": ">=0.27.14"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|