lazyclaw 3.99.28 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ // Read tool — reads a file (UTF-8) from the agent's workspace.
2
+ //
3
+ // Paths are resolved relative to the workspace cwd. Absolute paths are
4
+ // allowed (the user opted into "lazyclaw 모든 권한"). Use the bash tool
5
+ // to read binary blobs.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const NAME = 'read';
11
+ export const DESCRIPTION = 'Read a file from disk (UTF-8). Returns the file contents or an error.';
12
+ export const PARAMETERS = {
13
+ type: 'object',
14
+ properties: {
15
+ path: { type: 'string', description: 'Relative or absolute path to the file.' },
16
+ maxBytes: { type: 'number', description: 'Optional cap; default 500 KB. Larger files are truncated.' },
17
+ },
18
+ required: ['path'],
19
+ };
20
+
21
+ const DEFAULT_MAX_BYTES = 500_000;
22
+
23
+ export async function exec(args, { cwd = process.cwd() } = {}) {
24
+ if (!args || typeof args.path !== 'string' || !args.path) {
25
+ return { ok: false, error: 'read: path is required' };
26
+ }
27
+ const max = Math.max(1024, Math.min(Number.isFinite(+args.maxBytes) ? +args.maxBytes : DEFAULT_MAX_BYTES, 5_000_000));
28
+ const resolved = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
29
+ try {
30
+ const stat = fs.statSync(resolved);
31
+ if (!stat.isFile()) return { ok: false, error: `read: not a regular file: ${resolved}` };
32
+ const buf = fs.readFileSync(resolved);
33
+ const truncated = buf.length > max;
34
+ const slice = truncated ? buf.subarray(0, max) : buf;
35
+ return {
36
+ ok: true,
37
+ path: resolved,
38
+ bytes: stat.size,
39
+ content: slice.toString('utf8'),
40
+ truncated,
41
+ };
42
+ } catch (err) {
43
+ return { ok: false, error: `read: ${err?.message || err}` };
44
+ }
45
+ }
@@ -0,0 +1,42 @@
1
+ // Write tool — overwrites or creates a file with the given content.
2
+ //
3
+ // Creates parent directories as needed. Atomic-ish: writes to <path>.tmp
4
+ // then renames over the target so a crash mid-write doesn't leave a
5
+ // partially-written file.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const NAME = 'write';
11
+ export const DESCRIPTION = 'Create or overwrite a file with the given UTF-8 content. Returns {bytesWritten, path}.';
12
+ export const PARAMETERS = {
13
+ type: 'object',
14
+ properties: {
15
+ path: { type: 'string', description: 'Relative or absolute path to write.' },
16
+ content: { type: 'string', description: 'The new file contents (UTF-8).' },
17
+ },
18
+ required: ['path', 'content'],
19
+ };
20
+
21
+ export async function exec(args, { cwd = process.cwd() } = {}) {
22
+ if (!args || typeof args.path !== 'string' || !args.path) {
23
+ return { ok: false, error: 'write: path is required' };
24
+ }
25
+ if (typeof args.content !== 'string') {
26
+ return { ok: false, error: 'write: content must be a string' };
27
+ }
28
+ const resolved = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
29
+ try {
30
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
31
+ const tmp = resolved + '.tmp';
32
+ fs.writeFileSync(tmp, args.content);
33
+ fs.renameSync(tmp, resolved);
34
+ return {
35
+ ok: true,
36
+ path: resolved,
37
+ bytesWritten: Buffer.byteLength(args.content, 'utf8'),
38
+ };
39
+ } catch (err) {
40
+ return { ok: false, error: `write: ${err?.message || err}` };
41
+ }
42
+ }
package/memory.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // Layered memory for LazyClaw.
2
+ //
3
+ // Three storage shapes under <configDir>/memory/:
4
+ // core.md — single curated file. User-edited or LLM-edited.
5
+ // Long-lived; survives `dream()`. Mounted into
6
+ // every goal tick + every `/loop --use-memory`.
7
+ // recent.jsonl — append-only log of {sessionId, role, content,
8
+ // ts}, one line per call to sessions.appendTurn.
9
+ // Capped softly at RECENT_CAP entries; truncated
10
+ // hard to RECENT_KEEP_AFTER_DREAM after dream().
11
+ // episodic/<topic>.md — one file per topic produced by dream().
12
+ // Filenames are kebab-case slugs derived from
13
+ // the topic strings the provider returned.
14
+ //
15
+ // `appendRecent` is the only entry point sessions.appendTurn calls. It
16
+ // swallows every error — memory must not break the session-write path.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import os from 'node:os';
21
+
22
+ const MEMORY_DIRNAME = 'memory';
23
+ const RECENT_CAP = 200;
24
+ const RECENT_KEEP_AFTER_DREAM = 50;
25
+
26
+ export function defaultConfigDir() {
27
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
28
+ }
29
+
30
+ export function memoryDir(configDir = defaultConfigDir()) { return path.join(configDir, MEMORY_DIRNAME); }
31
+ export function corePath(configDir = defaultConfigDir()) { return path.join(memoryDir(configDir), 'core.md'); }
32
+ export function recentPath(configDir = defaultConfigDir()) { return path.join(memoryDir(configDir), 'recent.jsonl'); }
33
+ export function episodicDir(configDir = defaultConfigDir()) { return path.join(memoryDir(configDir), 'episodic'); }
34
+
35
+ export function loadCore(configDir = defaultConfigDir()) {
36
+ const p = corePath(configDir);
37
+ if (!fs.existsSync(p)) return '';
38
+ return fs.readFileSync(p, 'utf8');
39
+ }
40
+
41
+ export function setCore(text, configDir = defaultConfigDir()) {
42
+ fs.mkdirSync(memoryDir(configDir), { recursive: true });
43
+ fs.writeFileSync(corePath(configDir), String(text || ''));
44
+ }
45
+
46
+ export function loadRecent(n = 20, configDir = defaultConfigDir()) {
47
+ const p = recentPath(configDir);
48
+ if (!fs.existsSync(p)) return [];
49
+ const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
50
+ const slice = n > 0 ? lines.slice(-n) : lines;
51
+ const out = [];
52
+ for (const l of slice) {
53
+ try { out.push(JSON.parse(l)); } catch { /* skip malformed */ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function listEpisodic(configDir = defaultConfigDir()) {
59
+ const dir = episodicDir(configDir);
60
+ if (!fs.existsSync(dir)) return [];
61
+ return fs.readdirSync(dir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3));
62
+ }
63
+
64
+ export function loadEpisodic(topic, configDir = defaultConfigDir()) {
65
+ const p = path.join(episodicDir(configDir), `${topic}.md`);
66
+ if (!fs.existsSync(p)) return '';
67
+ return fs.readFileSync(p, 'utf8');
68
+ }
69
+
70
+ // Sync, swallowed-failure write-through called from sessions.appendTurn.
71
+ export function appendRecent(sessionId, role, content, configDir = defaultConfigDir()) {
72
+ try {
73
+ fs.mkdirSync(memoryDir(configDir), { recursive: true });
74
+ const line = JSON.stringify({
75
+ sessionId,
76
+ role,
77
+ content: String(content ?? ''),
78
+ ts: Date.now(),
79
+ }) + '\n';
80
+ fs.appendFileSync(recentPath(configDir), line);
81
+ // Cheap stat-based check to avoid read-rewrite on every append.
82
+ const st = fs.statSync(recentPath(configDir));
83
+ if (st.size > 1_000_000) {
84
+ const lines = fs.readFileSync(recentPath(configDir), 'utf8').split('\n').filter(Boolean);
85
+ if (lines.length > RECENT_CAP) {
86
+ fs.writeFileSync(recentPath(configDir), lines.slice(-RECENT_CAP).join('\n') + '\n');
87
+ }
88
+ }
89
+ } catch { /* swallow — memory failure must not break session writes */ }
90
+ }
91
+
92
+ // `dream(sessionId)` consolidates recent.jsonl into per-topic episodic
93
+ // files using the active provider, then truncates recent.jsonl to the
94
+ // last RECENT_KEEP_AFTER_DREAM entries. Returns { topics: [slug,...] }.
95
+ //
96
+ // The mock provider doesn't return JSON, so we accept any string and
97
+ // fall back to a single "recent-<date>" topic containing the raw reply.
98
+ // Real providers (Anthropic / OpenAI) typically obey the JSON instruction.
99
+ export async function dream(sessionId, { provider, model, apiKey } = {}, configDir = defaultConfigDir()) {
100
+ if (!provider) throw new Error('dream() requires a provider');
101
+ const turns = loadRecent(1000, configDir);
102
+ if (turns.length === 0) return { topics: [] };
103
+
104
+ const prompt = [
105
+ 'Below are recent chat turns. Group them under topics and summarise each topic in one paragraph.',
106
+ 'Dedupe aggressively. If two turns cover the same topic, merge.',
107
+ 'Output strict JSON of shape: {"topics": [{"topic": "kebab-case-slug", "summary": "..."}]}',
108
+ '',
109
+ 'Turns:',
110
+ ...turns.map(t => `- [${t.role}@${t.sessionId}]: ${String(t.content).slice(0, 500)}`),
111
+ ].join('\n');
112
+
113
+ let raw = '';
114
+ for await (const chunk of provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model })) {
115
+ raw += chunk;
116
+ }
117
+ let parsed = null;
118
+ try {
119
+ const m = raw.match(/\{[\s\S]*\}/);
120
+ parsed = m ? JSON.parse(m[0]) : null;
121
+ } catch { parsed = null; }
122
+ const topics = (parsed?.topics && Array.isArray(parsed.topics) && parsed.topics.length)
123
+ ? parsed.topics
124
+ : [{ topic: 'recent-' + new Date().toISOString().slice(0, 10), summary: raw.slice(0, 4000) || '(no content)' }];
125
+
126
+ fs.mkdirSync(episodicDir(configDir), { recursive: true });
127
+ const written = [];
128
+ for (const t of topics) {
129
+ if (!t || !t.topic) continue;
130
+ const slug = String(t.topic).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'untitled';
131
+ const p = path.join(episodicDir(configDir), `${slug}.md`);
132
+ fs.writeFileSync(p, `# ${slug}\n\n${t.summary || ''}\n`);
133
+ written.push(slug);
134
+ }
135
+
136
+ // Hard truncate recent.jsonl after a successful dream.
137
+ const rp = recentPath(configDir);
138
+ if (fs.existsSync(rp)) {
139
+ const lines = fs.readFileSync(rp, 'utf8').split('\n').filter(Boolean);
140
+ if (lines.length > RECENT_KEEP_AFTER_DREAM) {
141
+ fs.writeFileSync(rp, lines.slice(-RECENT_KEEP_AFTER_DREAM).join('\n') + '\n');
142
+ }
143
+ }
144
+ return { topics: written };
145
+ }
146
+
147
+ // Returns a single string suitable for prepending to a tick / loop
148
+ // prompt. Core memory always comes first; episodic files are included
149
+ // only when their topic slug substring-matches a word ≥3 chars from
150
+ // the goal name + description.
151
+ export function getMemoryForGoal(name, description = '', configDir = defaultConfigDir()) {
152
+ const parts = [];
153
+ const core = loadCore(configDir);
154
+ if (core.trim()) parts.push(`## Core memory\n${core}`);
155
+ const keywords = String(name + ' ' + description).toLowerCase()
156
+ .split(/[^a-z0-9]+/).filter(w => w.length >= 3);
157
+ if (keywords.length) {
158
+ const topics = listEpisodic(configDir);
159
+ for (const t of topics) {
160
+ const tl = t.toLowerCase();
161
+ if (keywords.some(k => tl.includes(k))) {
162
+ const body = loadEpisodic(t, configDir);
163
+ if (body.trim()) parts.push(`## Episodic: ${t}\n${body}`);
164
+ }
165
+ }
166
+ }
167
+ return parts.join('\n\n');
168
+ }
169
+
170
+ // Recall helper used by `/loop --recall "<query>"`. Tokenises the query,
171
+ // scores recent and episodic entries by overlap, returns the top-N
172
+ // matches as a single concatenated string. Cheap, no external index.
173
+ export function recall(query, { topN = 3 } = {}, configDir = defaultConfigDir()) {
174
+ const tokens = String(query || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 3);
175
+ if (!tokens.length) return '';
176
+ const candidates = [];
177
+ for (const slug of listEpisodic(configDir)) {
178
+ const body = loadEpisodic(slug, configDir);
179
+ candidates.push({ source: `episodic:${slug}`, body });
180
+ }
181
+ for (const turn of loadRecent(200, configDir)) {
182
+ candidates.push({ source: `recent:${turn.sessionId || '?'}`, body: String(turn.content || '') });
183
+ }
184
+ const scored = candidates.map((c) => {
185
+ const lower = c.body.toLowerCase();
186
+ let score = 0;
187
+ for (const t of tokens) {
188
+ if (lower.includes(t)) score += 1;
189
+ }
190
+ return { ...c, score };
191
+ }).filter(c => c.score > 0).sort((a, b) => b.score - a.score).slice(0, topN);
192
+ return scored.map(c => `## ${c.source} (score ${c.score})\n${c.body}`).join('\n\n');
193
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.28",
4
- "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
3
+ "version": "4.2.0",
4
+ "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "anthropic",
@@ -15,22 +15,25 @@
15
15
  "workflow",
16
16
  "agent"
17
17
  ],
18
- "homepage": "https://github.com/cmblir/LazyClaude",
18
+ "homepage": "https://github.com/cmblir/lazyclaw#readme",
19
19
  "bugs": {
20
- "url": "https://github.com/cmblir/LazyClaude/issues"
20
+ "url": "https://github.com/cmblir/lazyclaw/issues"
21
21
  },
22
22
  "license": "MIT",
23
23
  "author": "cmblir",
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "git+https://github.com/cmblir/LazyClaude.git",
27
- "directory": "src/lazyclaw"
26
+ "url": "git+https://github.com/cmblir/lazyclaw.git"
28
27
  },
29
28
  "type": "module",
30
29
  "main": "cli.mjs",
31
30
  "bin": {
32
31
  "lazyclaw": "cli.mjs"
33
32
  },
33
+ "scripts": {
34
+ "test": "playwright test",
35
+ "test:bench": "node scripts/bench-providers.mjs"
36
+ },
34
37
  "files": [
35
38
  "cli.mjs",
36
39
  "daemon.mjs",
@@ -46,15 +49,32 @@
46
49
  "sandbox.mjs",
47
50
  "skills_install.mjs",
48
51
  "cron.mjs",
52
+ "loop-engine.mjs",
53
+ "loops.mjs",
54
+ "goals.mjs",
55
+ "memory.mjs",
56
+ "agents.mjs",
57
+ "teams.mjs",
58
+ "tasks.mjs",
59
+ "channels/",
49
60
  "providers/",
50
61
  "workflow/",
51
62
  "web/",
63
+ "mas/",
64
+ "docs/multi-agent.md",
65
+ "scripts/loop-worker.mjs",
52
66
  "README.md",
53
67
  "LICENSE"
54
68
  ],
55
69
  "engines": {
56
70
  "node": ">=18"
57
71
  },
72
+ "devDependencies": {
73
+ "@playwright/test": "^1.59.1",
74
+ "@types/node": "^25.6.0",
75
+ "playwright": "^1.59.1",
76
+ "typescript": "^6.0.3"
77
+ },
58
78
  "publishConfig": {
59
79
  "access": "public"
60
80
  }
@@ -42,7 +42,14 @@ export const mockProvider = {
42
42
  name: 'mock',
43
43
  async *sendMessage(messages, opts = {}) {
44
44
  const last = messages[messages.length - 1];
45
- const reply = `mock-reply: ${last?.content ?? ''}`;
45
+ const sys = messages.find((m) => m.role === 'system')?.content || '';
46
+ // When a system message is present, prefix the echo with [sys:...]
47
+ // so callers (and especially tests) can verify what the provider
48
+ // saw in the system slot. No system → byte-identical to the prior
49
+ // shape so existing assertions stay green.
50
+ const reply = sys
51
+ ? `[sys:${String(sys).slice(0, 8000)}]\nmock-reply: ${last?.content ?? ''}`
52
+ : `mock-reply: ${last?.content ?? ''}`;
46
53
  // Honor opts.signal so the chat REPL's Ctrl+C handler (and any
47
54
  // other caller) can stop the stream mid-flight. The other concrete
48
55
  // providers already do this; the mock should match for symmetry.
@@ -0,0 +1,151 @@
1
+ // Anthropic tool-use adapter.
2
+ //
3
+ // Unlike providers/anthropic.mjs (which streams a single text response),
4
+ // this module makes ONE non-streaming Messages API call at a time and
5
+ // parses the result into a normalized envelope the agent-turn runner
6
+ // can act on:
7
+ //
8
+ // { kind: 'final', text }
9
+ // { kind: 'tool_calls', text?, calls: [{id, name, input}], assistantContent }
10
+ //
11
+ // `assistantContent` is the raw `content` array from the API response;
12
+ // the caller echoes it back verbatim on the next request so the model
13
+ // can correlate tool_result blocks with the right tool_use ids.
14
+ //
15
+ // Anthropic's docs (Messages API tool-use, accessed Jan 2026):
16
+ // https://docs.anthropic.com/en/api/messages
17
+ // https://docs.anthropic.com/en/docs/build-with-claude/tool-use
18
+
19
+ const ANTHROPIC_VERSION = '2023-06-01';
20
+ const DEFAULT_MAX_TOKENS = 4096;
21
+ const DEFAULT_BASE = 'https://api.anthropic.com/v1';
22
+
23
+ export class AnthropicToolUseError extends Error {
24
+ constructor(message, code, body) {
25
+ super(message);
26
+ this.name = 'AnthropicToolUseError';
27
+ this.code = code || 'ANTHROPIC_ERR';
28
+ if (body) this.body = body;
29
+ }
30
+ }
31
+
32
+ // Convert a registry schema entry into Anthropic's `input_schema` shape.
33
+ // Today the two are identical (both JSON Schema). We materialise this
34
+ // helper anyway so future divergences (e.g. Anthropic-specific keys)
35
+ // have a single place to land.
36
+ export function toAnthropicTools(schemas) {
37
+ return (schemas || []).map((s) => ({
38
+ name: s.name,
39
+ description: s.description,
40
+ input_schema: s.parameters,
41
+ }));
42
+ }
43
+
44
+ export async function callOnce({
45
+ messages,
46
+ tools = [],
47
+ model,
48
+ apiKey,
49
+ system,
50
+ maxTokens = DEFAULT_MAX_TOKENS,
51
+ baseUrl,
52
+ fetchImpl,
53
+ signal,
54
+ } = {}) {
55
+ if (!Array.isArray(messages) || messages.length === 0) {
56
+ throw new AnthropicToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
57
+ }
58
+ if (!apiKey) {
59
+ throw new AnthropicToolUseError('apiKey is required', 'NO_API_KEY');
60
+ }
61
+ const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/messages`;
62
+ const fetchFn = fetchImpl || globalThis.fetch;
63
+ const body = {
64
+ model: model || 'claude-opus-4-7',
65
+ max_tokens: maxTokens,
66
+ messages,
67
+ };
68
+ if (system && String(system).trim()) body.system = String(system);
69
+ if (tools && tools.length) body.tools = tools;
70
+
71
+ const res = await fetchFn(url, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'x-api-key': apiKey,
76
+ 'anthropic-version': ANTHROPIC_VERSION,
77
+ },
78
+ body: JSON.stringify(body),
79
+ signal,
80
+ });
81
+ if (!res.ok) {
82
+ let raw = '';
83
+ try { raw = await res.text(); } catch { /* ignore */ }
84
+ throw new AnthropicToolUseError(`HTTP ${res.status}: ${raw.slice(0, 300)}`, 'HTTP_FAIL', raw);
85
+ }
86
+ const json = await res.json();
87
+ return parseResponse(json);
88
+ }
89
+
90
+ export function parseResponse(json) {
91
+ const content = Array.isArray(json?.content) ? json.content : [];
92
+ const textParts = [];
93
+ const calls = [];
94
+ for (const block of content) {
95
+ if (!block || typeof block !== 'object') continue;
96
+ if (block.type === 'text' && typeof block.text === 'string') {
97
+ textParts.push(block.text);
98
+ } else if (block.type === 'tool_use') {
99
+ if (!block.id || !block.name) continue;
100
+ calls.push({ id: block.id, name: block.name, input: block.input ?? {} });
101
+ }
102
+ }
103
+ const text = textParts.join('');
104
+ if (calls.length === 0) {
105
+ return { kind: 'final', text, raw: json };
106
+ }
107
+ return { kind: 'tool_calls', text, calls, assistantContent: content, raw: json };
108
+ }
109
+
110
+ // Anthropic accepts the agent_turn-native {role, content} shape
111
+ // directly; no transformation needed. These helpers exist so the
112
+ // agent-turn runner can call the same names across all adapters.
113
+ export function normalizeHistory(turns) {
114
+ return Array.isArray(turns) ? [...turns] : [];
115
+ }
116
+
117
+ export function initialUserMessage(text) {
118
+ return { role: 'user', content: String(text) };
119
+ }
120
+
121
+ // Build the `messages` entries the runner appends to record the model's
122
+ // own turn. Anthropic packs everything into one assistant message whose
123
+ // content is the array of blocks the API returned, so we return that
124
+ // inside a one-element array for shape parity with adapters that need
125
+ // multiple entries (OpenAI).
126
+ export function assistantTurnMessages(resp) {
127
+ return [{ role: 'assistant', content: resp.assistantContent }];
128
+ }
129
+
130
+ // Build the `messages` entries the runner appends after executing tools
131
+ // so the next callOnce request has correctly-shaped tool_result blocks.
132
+ // Anthropic groups all results inside a single user-role message; the
133
+ // array wrapper exists for shape parity with adapters that emit one
134
+ // tool-result message per call (OpenAI).
135
+ //
136
+ // `results` is an array aligned with the assistant turn's tool_calls:
137
+ // [{ id, content, isError? }]
138
+ export function toolResultMessages(results) {
139
+ const content = (results || []).map((r) => ({
140
+ type: 'tool_result',
141
+ tool_use_id: r.id,
142
+ content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
143
+ ...(r.isError ? { is_error: true } : {}),
144
+ }));
145
+ return [{ role: 'user', content }];
146
+ }
147
+
148
+ // Back-compat alias kept until external callers migrate.
149
+ export function buildToolResultsMessage(results) {
150
+ return toolResultMessages(results)[0];
151
+ }