@yeaft/webchat-agent 0.1.511 → 0.1.512
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/package.json +1 -1
- package/unify/engine.js +55 -4
- package/unify/memory/layout.js +1 -1
- package/unify/prompts.js +40 -5
- package/unify/tools/index.js +5 -5
- package/unify/tools/js-repl.js +42 -15
- package/unify/tools/memory-query.js +3 -1
- package/unify/tools/memory-search.js +102 -63
- package/unify/tools/task-tools.js +22 -5
- package/unify/tools/thread-tools.js +16 -11
- package/unify/tools/view-image.js +249 -50
- package/unify/tools/tool-search.js +0 -88
- package/unify/tools/write-stdin.js +0 -53
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -43,6 +43,47 @@ import { normalizeEffort } from './models.js';
|
|
|
43
43
|
/** Maximum auto-continue turns when stopReason is 'max_tokens'. */
|
|
44
44
|
const MAX_CONTINUE_TURNS = 3;
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* task-331 — Map a conversationMessages entry into the snapshot shape used
|
|
48
|
+
* by `debug_turn.messages`. Preserves the function-calling metadata that
|
|
49
|
+
* the Debug panel needs to render:
|
|
50
|
+
* - `toolCalls` on assistant turns (the LLM's function_call requests)
|
|
51
|
+
* - `toolCallId` + `isError` on tool turns (the paired tool_result)
|
|
52
|
+
*
|
|
53
|
+
* Content is truncated at 50000 chars; each tool_call input is JSON-stringified
|
|
54
|
+
* + sliced at 10000 chars before being re-parsed, so a runaway `input` blob
|
|
55
|
+
* can't blow past the WebSocket frame budget. Unknown roles pass through
|
|
56
|
+
* unchanged.
|
|
57
|
+
*
|
|
58
|
+
* Pure function — no side effects on the input message.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ role: string, content?: any, toolCalls?: Array, toolCallId?: string, isError?: boolean }} m
|
|
61
|
+
* @returns {{ role: string, content: any, toolCalls?: Array, toolCallId?: string, isError?: boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function mapDebugMessage(m) {
|
|
64
|
+
const out = { role: m.role };
|
|
65
|
+
out.content = typeof m.content === 'string' ? m.content.slice(0, 50000) : m.content;
|
|
66
|
+
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
67
|
+
out.toolCalls = m.toolCalls.map(tc => {
|
|
68
|
+
let input = tc.input;
|
|
69
|
+
try {
|
|
70
|
+
const s = JSON.stringify(input);
|
|
71
|
+
if (typeof s === 'string' && s.length > 10000) {
|
|
72
|
+
input = { __truncated: true, preview: s.slice(0, 10000) };
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Non-serializable input — fall through with raw reference; the
|
|
76
|
+
// frontend's JSON.stringify will hit the same failure and replace
|
|
77
|
+
// it with a placeholder string.
|
|
78
|
+
}
|
|
79
|
+
return { id: tc.id, name: tc.name, input };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (m.toolCallId) out.toolCallId = m.toolCallId;
|
|
83
|
+
if (m.isError != null) out.isError = m.isError;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
46
87
|
// ─── Engine Events (superset of adapter events) ──────────────────
|
|
47
88
|
|
|
48
89
|
/**
|
|
@@ -289,6 +330,13 @@ export class Engine {
|
|
|
289
330
|
conversationStore: this.#conversationStore,
|
|
290
331
|
adapter: this.#adapter,
|
|
291
332
|
config: this.#config,
|
|
333
|
+
// ViewImage (task-333b PR-B rev-3 P1-A): expose size cap + allowlist
|
|
334
|
+
// via tool ctx so hosts can override via ~/.yeaft/config.json without
|
|
335
|
+
// touching the tool impl.
|
|
336
|
+
maxImageBytes: this.#config?.unify?.maxImageBytes,
|
|
337
|
+
imageAllowlist: Array.isArray(this.#config?.unify?.imageAllowlist)
|
|
338
|
+
? this.#config.unify.imageAllowlist
|
|
339
|
+
: [],
|
|
292
340
|
};
|
|
293
341
|
}
|
|
294
342
|
|
|
@@ -508,8 +556,8 @@ export class Engine {
|
|
|
508
556
|
|
|
509
557
|
// ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
|
|
510
558
|
// New layout: always inject Memory Index + user-preferences + project
|
|
511
|
-
// header excerpt. No per-turn fuzzy recall — LLM calls
|
|
512
|
-
// memory_query on demand.
|
|
559
|
+
// header excerpt. No per-turn fuzzy recall — LLM calls memory_load /
|
|
560
|
+
// memory_query on demand (memory_search still works as a deprecated alias).
|
|
513
561
|
let memoryInjection = '';
|
|
514
562
|
if (this.#yeaftDir) {
|
|
515
563
|
try {
|
|
@@ -634,7 +682,7 @@ export class Engine {
|
|
|
634
682
|
turnNumber,
|
|
635
683
|
model: currentModel,
|
|
636
684
|
systemPrompt,
|
|
637
|
-
messages: conversationMessages.map(
|
|
685
|
+
messages: conversationMessages.map(mapDebugMessage),
|
|
638
686
|
response: responseText || `Error: ${err.message}`,
|
|
639
687
|
toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
|
|
640
688
|
usage: { inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens },
|
|
@@ -702,12 +750,15 @@ export class Engine {
|
|
|
702
750
|
|
|
703
751
|
// Emit debug_turn event for web UI debug panel
|
|
704
752
|
// (conversationMessages does NOT yet include the assistant response at this point)
|
|
753
|
+
// task-331: preserve toolCalls / toolCallId / isError on each message so
|
|
754
|
+
// the Debug panel can render function_call requests and their paired
|
|
755
|
+
// tool_result responses across turns.
|
|
705
756
|
yield {
|
|
706
757
|
type: 'debug_turn',
|
|
707
758
|
turnNumber,
|
|
708
759
|
model: currentModel,
|
|
709
760
|
systemPrompt,
|
|
710
|
-
messages: conversationMessages.map(
|
|
761
|
+
messages: conversationMessages.map(mapDebugMessage),
|
|
711
762
|
response: responseText,
|
|
712
763
|
toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
|
|
713
764
|
usage: { inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens },
|
package/unify/memory/layout.js
CHANGED
|
@@ -265,7 +265,7 @@ export function renderIndex(yeaftDir, entryCount) {
|
|
|
265
265
|
lines.push('## entries', '', `- ${entryCount} atomic entries (use memory_query to search)`, '');
|
|
266
266
|
|
|
267
267
|
lines.push(
|
|
268
|
-
'_Note: use the `
|
|
268
|
+
'_Note: use the `memory_load` tool with one or more paths to load a classification',
|
|
269
269
|
'file in full, or `memory_query` to search atomic entries by keywords/tags._',
|
|
270
270
|
);
|
|
271
271
|
|
package/unify/prompts.js
CHANGED
|
@@ -32,18 +32,53 @@ const TEMPLATES_DIR = join(__dirname, 'templates');
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Read a template file from the templates/ directory.
|
|
35
|
-
*
|
|
35
|
+
*
|
|
36
|
+
* task-332c F3 — missing-template guard:
|
|
37
|
+
* Required templates MUST be present. If a required template is missing or
|
|
38
|
+
* unreadable, throw a clear error instead of silently degrading to the
|
|
39
|
+
* hardcoded fallback. Silent skip previously hid misconfigured deployments
|
|
40
|
+
* (empty prompts shipped to production), so we now fail fast at load time.
|
|
41
|
+
*
|
|
42
|
+
* Non-required templates (passed with { required: false }) retain the old
|
|
43
|
+
* "return empty string on absence" behavior for optional inclusions.
|
|
44
|
+
*
|
|
36
45
|
* @param {string} name — filename (e.g. 'base.md')
|
|
46
|
+
* @param {{ required?: boolean }} [opts]
|
|
37
47
|
* @returns {string}
|
|
48
|
+
* @throws {Error} when required=true and the file is missing / unreadable / empty
|
|
38
49
|
*/
|
|
39
|
-
function readTemplate(name) {
|
|
50
|
+
function readTemplate(name, { required = true } = {}) {
|
|
40
51
|
const path = join(TEMPLATES_DIR, name);
|
|
41
|
-
if (!existsSync(path))
|
|
52
|
+
if (!existsSync(path)) {
|
|
53
|
+
if (required) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[prompts] Required template missing: ${name} ` +
|
|
56
|
+
`(expected at ${path}). Templates are part of the agent package — ` +
|
|
57
|
+
`check the install or build output.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
let content;
|
|
42
63
|
try {
|
|
43
|
-
|
|
44
|
-
} catch {
|
|
64
|
+
content = readFileSync(path, 'utf8');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (required) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[prompts] Required template unreadable: ${name} ` +
|
|
69
|
+
`(at ${path}): ${e.message}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
45
72
|
return '';
|
|
46
73
|
}
|
|
74
|
+
const trimmed = content.trim();
|
|
75
|
+
if (!trimmed && required) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[prompts] Required template is empty: ${name} (at ${path}). ` +
|
|
78
|
+
`An empty system prompt template would ship a degenerate prompt to the LLM.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return trimmed;
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
/**
|
package/unify/tools/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import exitWorktree from './exit-worktree.js';
|
|
|
20
20
|
import askUser from './ask-user.js';
|
|
21
21
|
import memoryRead from './memory-read.js';
|
|
22
22
|
import memoryWrite from './memory-write.js';
|
|
23
|
-
import memorySearch from './memory-search.js';
|
|
23
|
+
import memorySearch, { memorySearchAlias } from './memory-search.js';
|
|
24
24
|
import memoryQuery from './memory-query.js';
|
|
25
25
|
import webSearch from './web-search.js';
|
|
26
26
|
import webFetch from './web-fetch.js';
|
|
@@ -67,13 +67,14 @@ import {
|
|
|
67
67
|
} from './thread-tools.js';
|
|
68
68
|
|
|
69
69
|
// --- P2 Auxiliary tools ---
|
|
70
|
+
// task-333b L1 delete: ToolSearch and WriteStdin removed — the function-call
|
|
71
|
+
// schema already exposes all tools, so ToolSearch was redundant; WriteStdin
|
|
72
|
+
// was a stub returning a hint about Bash piping.
|
|
70
73
|
import { jsRepl, jsReplReset } from './js-repl.js';
|
|
71
74
|
import notebookEdit from './notebook-edit.js';
|
|
72
75
|
import imageGeneration from './image-generation.js';
|
|
73
76
|
import viewImage from './view-image.js';
|
|
74
|
-
import toolSearch from './tool-search.js';
|
|
75
77
|
import requestPermissions from './request-permissions.js';
|
|
76
|
-
import writeStdin from './write-stdin.js';
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
80
|
* All built-in tools, flattened into a single array.
|
|
@@ -92,6 +93,7 @@ export const allTools = [
|
|
|
92
93
|
memoryRead,
|
|
93
94
|
memoryWrite,
|
|
94
95
|
memorySearch,
|
|
96
|
+
memorySearchAlias,
|
|
95
97
|
memoryQuery,
|
|
96
98
|
webSearch,
|
|
97
99
|
webFetch,
|
|
@@ -139,9 +141,7 @@ export const allTools = [
|
|
|
139
141
|
notebookEdit,
|
|
140
142
|
imageGeneration,
|
|
141
143
|
viewImage,
|
|
142
|
-
toolSearch,
|
|
143
144
|
requestPermissions,
|
|
144
|
-
writeStdin,
|
|
145
145
|
];
|
|
146
146
|
|
|
147
147
|
/**
|
package/unify/tools/js-repl.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs JavaScript code in a persistent VM context, allowing
|
|
5
5
|
* state to be maintained across calls.
|
|
6
|
+
*
|
|
7
|
+
* task-333b: merged the former `JsReplReset` tool into this one as a
|
|
8
|
+
* `reset: true` parameter. A single tool with a reset flag eliminates a
|
|
9
|
+
* duplicate schema entry in the function-call catalogue. The old
|
|
10
|
+
* JsReplReset name is kept below as a deprecated alias for one release.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { defineTool } from './types.js';
|
|
@@ -55,7 +60,9 @@ defined in one call are available in subsequent calls.
|
|
|
55
60
|
|
|
56
61
|
Guidelines:
|
|
57
62
|
- Use for calculations, data transformations, and quick experiments
|
|
58
|
-
- State is preserved between calls
|
|
63
|
+
- State is preserved between calls
|
|
64
|
+
- Pass \`reset: true\` to wipe all state before evaluating (clean slate).
|
|
65
|
+
\`code\` becomes optional in this mode — pass reset alone to just clear.
|
|
59
66
|
- console.log output is captured and returned
|
|
60
67
|
- Returns the last expression's value plus any console output
|
|
61
68
|
- No filesystem or network access from within the REPL`,
|
|
@@ -64,16 +71,27 @@ Guidelines:
|
|
|
64
71
|
properties: {
|
|
65
72
|
code: {
|
|
66
73
|
type: 'string',
|
|
67
|
-
description: 'JavaScript code to evaluate',
|
|
74
|
+
description: 'JavaScript code to evaluate. Optional when reset=true and you only want to clear state.',
|
|
75
|
+
},
|
|
76
|
+
reset: {
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
description: 'When true, reset the REPL context BEFORE evaluating `code`. If `code` is omitted, just resets.',
|
|
68
79
|
},
|
|
69
80
|
},
|
|
70
|
-
required: ['code'],
|
|
71
81
|
},
|
|
72
82
|
isConcurrencySafe: () => false,
|
|
73
83
|
isReadOnly: () => true,
|
|
74
84
|
async execute(input, ctx) {
|
|
75
|
-
const { code } = input;
|
|
76
|
-
|
|
85
|
+
const { code, reset } = input || {};
|
|
86
|
+
|
|
87
|
+
if (reset) {
|
|
88
|
+
vmContext = null;
|
|
89
|
+
if (!code) {
|
|
90
|
+
return JSON.stringify({ success: true, message: 'REPL context reset' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!code) return JSON.stringify({ error: 'code is required (or pass reset=true to clear state)' });
|
|
77
95
|
|
|
78
96
|
const vmCtx = getContext();
|
|
79
97
|
|
|
@@ -87,6 +105,7 @@ Guidelines:
|
|
|
87
105
|
const resultStr = result === undefined ? '' : String(result);
|
|
88
106
|
|
|
89
107
|
const parts = [];
|
|
108
|
+
if (reset) parts.push('(REPL context reset)');
|
|
90
109
|
if (output.length > 0) parts.push(output.join('\n'));
|
|
91
110
|
if (resultStr) parts.push(`→ ${resultStr}`);
|
|
92
111
|
|
|
@@ -101,20 +120,28 @@ Guidelines:
|
|
|
101
120
|
},
|
|
102
121
|
});
|
|
103
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Deprecated alias — `JsReplReset`. task-333b merged reset behaviour into
|
|
125
|
+
* `JsRepl` via `reset: true`. Kept registered for one release so older
|
|
126
|
+
* prompts / saved tool calls still resolve. Emits a one-time deprecation
|
|
127
|
+
* warning on first invocation, then delegates to jsRepl.execute with reset.
|
|
128
|
+
*/
|
|
129
|
+
const _jsReplResetWarned = { v: false };
|
|
130
|
+
function warnJsReplResetDeprecated() {
|
|
131
|
+
if (_jsReplResetWarned.v) return;
|
|
132
|
+
_jsReplResetWarned.v = true;
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
console.warn('[deprecated] JsReplReset → JsRepl. Call JsRepl with { reset: true } to clear REPL state.');
|
|
135
|
+
}
|
|
136
|
+
|
|
104
137
|
export const jsReplReset = defineTool({
|
|
105
138
|
name: 'JsReplReset',
|
|
106
|
-
description: `
|
|
107
|
-
|
|
108
|
-
Clears all variables and state from previous evaluations.
|
|
109
|
-
Use when you want a clean slate.`,
|
|
110
|
-
parameters: {
|
|
111
|
-
type: 'object',
|
|
112
|
-
properties: {},
|
|
113
|
-
},
|
|
139
|
+
description: 'DEPRECATED — use JsRepl with `reset: true` instead. Resets the persistent REPL context. Removal target: v0.2.0.',
|
|
140
|
+
parameters: { type: 'object', properties: {} },
|
|
114
141
|
isConcurrencySafe: () => false,
|
|
115
142
|
isReadOnly: () => false,
|
|
116
143
|
async execute(input, ctx) {
|
|
117
|
-
|
|
118
|
-
return
|
|
144
|
+
warnJsReplResetDeprecated();
|
|
145
|
+
return jsRepl.execute({ reset: true }, ctx);
|
|
119
146
|
},
|
|
120
147
|
});
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* 3. Returns a compact list suitable for LLM consumption
|
|
11
11
|
*
|
|
12
12
|
* Use this for fuzzy discovery over atomic entries. For loading a known
|
|
13
|
-
* classification file in full, use `
|
|
13
|
+
* classification file in full, use `memory_load` instead (renamed from
|
|
14
|
+
* the old `memory_search` in task-333b; the old name still works as a
|
|
15
|
+
* deprecated alias for one release).
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import { defineTool } from './types.js';
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory-search.js
|
|
2
|
+
* memory-search.js → memory_load (task-333b rename).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* This tool loads memory classification files by path (it is NOT a search).
|
|
5
|
+
* task-333b renamed it from `memory_search` → `memory_load` so the name
|
|
6
|
+
* actually reflects behaviour; `memory_query` remains the fuzzy search tool.
|
|
7
|
+
*
|
|
8
|
+
* Backwards compatibility: a thin alias tool named `memory_search` is also
|
|
9
|
+
* exported (see bottom of file) so older transcripts/prompts still resolve.
|
|
5
10
|
*
|
|
6
11
|
* The system prompt injects `index.md` every turn, which lists all available
|
|
7
12
|
* classification files under `~/.yeaft/memory/` (single files + by-project /
|
|
@@ -9,9 +14,6 @@
|
|
|
9
14
|
* it calls this tool with `paths: [...]` to load one or more of those files
|
|
10
15
|
* in full.
|
|
11
16
|
*
|
|
12
|
-
* This is NOT a fuzzy search. It is a precise file loader. Use `memory_query`
|
|
13
|
-
* for fuzzy search over atomic entries.
|
|
14
|
-
*
|
|
15
17
|
* Only paths under `memory/` are accepted. `..` segments are rejected.
|
|
16
18
|
*/
|
|
17
19
|
|
|
@@ -21,9 +23,7 @@ import { readMemoryFile, listClassificationFiles } from '../memory/layout.js';
|
|
|
21
23
|
const MAX_FILES_PER_CALL = 5;
|
|
22
24
|
const MAX_BYTES_PER_FILE = 32000;
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
name: 'memory_search',
|
|
26
|
-
description: `Load one or more memory classification files in full.
|
|
26
|
+
const DESCRIPTION = `Load one or more memory classification files in full.
|
|
27
27
|
|
|
28
28
|
Paths are relative to ~/.yeaft/memory/. Allowed targets:
|
|
29
29
|
- user-preferences.md — merged user preferences
|
|
@@ -36,66 +36,105 @@ of available files. Use this tool when the index suggests a file is relevant
|
|
|
36
36
|
to the user's current request. For fuzzy search over atomic memory entries
|
|
37
37
|
(facts, lessons, preferences), use the memory_query tool instead.
|
|
38
38
|
|
|
39
|
-
Up to ${MAX_FILES_PER_CALL} files per call. Each file is capped at ${MAX_BYTES_PER_FILE} bytes
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
Up to ${MAX_FILES_PER_CALL} files per call. Each file is capped at ${MAX_BYTES_PER_FILE} bytes.`;
|
|
40
|
+
|
|
41
|
+
const PARAMETERS = {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
paths: {
|
|
45
|
+
type: 'array',
|
|
46
|
+
items: { type: 'string' },
|
|
47
|
+
description: 'Relative paths under memory/. Example: ["by-project/claude-web-chat.md", "user-preferences.md"]',
|
|
48
48
|
},
|
|
49
|
-
required: ['paths'],
|
|
50
49
|
},
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
async execute(input, ctx) {
|
|
54
|
-
const yeaftDir = ctx?.yeaftDir;
|
|
55
|
-
if (!yeaftDir) {
|
|
56
|
-
return JSON.stringify({ error: 'Memory system not initialized (no yeaftDir in context)' });
|
|
57
|
-
}
|
|
50
|
+
required: ['paths'],
|
|
51
|
+
};
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
async function executeLoad(input, ctx) {
|
|
54
|
+
const yeaftDir = ctx?.yeaftDir;
|
|
55
|
+
if (!yeaftDir) {
|
|
56
|
+
return JSON.stringify({ error: 'Memory system not initialized (no yeaftDir in context)' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const paths = Array.isArray(input?.paths) ? input.paths : [];
|
|
60
|
+
if (paths.length === 0) {
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
error: 'paths is required and must be a non-empty string array',
|
|
63
|
+
availablePaths: listClassificationFiles(yeaftDir).map(f => f.path),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const results = [];
|
|
68
|
+
const errors = [];
|
|
69
|
+
|
|
70
|
+
for (const rel of paths.slice(0, MAX_FILES_PER_CALL)) {
|
|
71
|
+
if (typeof rel !== 'string' || !rel.trim()) {
|
|
72
|
+
errors.push({ path: rel, error: 'not a non-empty string' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (rel.includes('..') || rel.startsWith('/')) {
|
|
76
|
+
errors.push({ path: rel, error: 'path must be relative and must not contain ..' });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!rel.endsWith('.md')) {
|
|
80
|
+
errors.push({ path: rel, error: 'only .md files are supported' });
|
|
81
|
+
continue;
|
|
65
82
|
}
|
|
66
83
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (typeof rel !== 'string' || !rel.trim()) {
|
|
72
|
-
errors.push({ path: rel, error: 'not a non-empty string' });
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
if (rel.includes('..') || rel.startsWith('/')) {
|
|
76
|
-
errors.push({ path: rel, error: 'path must be relative and must not contain ..' });
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (!rel.endsWith('.md')) {
|
|
80
|
-
errors.push({ path: rel, error: 'only .md files are supported' });
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const text = readMemoryFile(yeaftDir, rel);
|
|
85
|
-
if (!text) {
|
|
86
|
-
errors.push({ path: rel, error: 'file not found or empty' });
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const truncated = text.length > MAX_BYTES_PER_FILE;
|
|
91
|
-
results.push({
|
|
92
|
-
path: rel,
|
|
93
|
-
content: truncated ? text.slice(0, MAX_BYTES_PER_FILE) : text,
|
|
94
|
-
truncated,
|
|
95
|
-
size: text.length,
|
|
96
|
-
});
|
|
84
|
+
const text = readMemoryFile(yeaftDir, rel);
|
|
85
|
+
if (!text) {
|
|
86
|
+
errors.push({ path: rel, error: 'file not found or empty' });
|
|
87
|
+
continue;
|
|
97
88
|
}
|
|
98
89
|
|
|
99
|
-
|
|
100
|
-
|
|
90
|
+
const truncated = text.length > MAX_BYTES_PER_FILE;
|
|
91
|
+
results.push({
|
|
92
|
+
path: rel,
|
|
93
|
+
content: truncated ? text.slice(0, MAX_BYTES_PER_FILE) : text,
|
|
94
|
+
truncated,
|
|
95
|
+
size: text.length,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return JSON.stringify({ results, errors }, null, 2);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Canonical tool — `memory_load`. This is the default export so existing
|
|
104
|
+
* import sites (`import memorySearch from './memory-search.js'`) keep
|
|
105
|
+
* working; the renamed identity is expressed via the tool's `name`.
|
|
106
|
+
*/
|
|
107
|
+
const memoryLoad = defineTool({
|
|
108
|
+
name: 'memory_load',
|
|
109
|
+
description: DESCRIPTION,
|
|
110
|
+
parameters: PARAMETERS,
|
|
111
|
+
isConcurrencySafe: () => true,
|
|
112
|
+
isReadOnly: () => true,
|
|
113
|
+
execute: executeLoad,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Deprecated alias — `memory_search`. Kept so older prompts / saved tool
|
|
118
|
+
* calls still resolve. Delegates to the same executor. Do not use for new
|
|
119
|
+
* call sites. Emits a one-time console.warn on first invocation.
|
|
120
|
+
*/
|
|
121
|
+
const _memSearchWarned = { v: false };
|
|
122
|
+
async function executeLoadWithWarn(input, ctx) {
|
|
123
|
+
if (!_memSearchWarned.v) {
|
|
124
|
+
_memSearchWarned.v = true;
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.warn('[deprecated] memory_search → memory_load. Use memory_load for path-based file loading; use memory_query for fuzzy keyword search.');
|
|
127
|
+
}
|
|
128
|
+
return executeLoad(input, ctx);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const memorySearchAlias = defineTool({
|
|
132
|
+
name: 'memory_search',
|
|
133
|
+
description: 'DEPRECATED — use memory_load. Same params. Removal target: v0.2.0.',
|
|
134
|
+
parameters: PARAMETERS,
|
|
135
|
+
isConcurrencySafe: () => true,
|
|
136
|
+
isReadOnly: () => true,
|
|
137
|
+
execute: executeLoadWithWarn,
|
|
101
138
|
});
|
|
139
|
+
|
|
140
|
+
export default memoryLoad;
|
|
@@ -48,7 +48,9 @@ export const taskCreate = defineTool({
|
|
|
48
48
|
|
|
49
49
|
Tasks have a title, description, priority, and status.
|
|
50
50
|
Each task gets its own folder with task.md, progress.md, and memory.md.
|
|
51
|
-
Use this to break down complex work into trackable items
|
|
51
|
+
Use this to break down complex work into trackable items.
|
|
52
|
+
|
|
53
|
+
Pass \`parent_id\` to create a subtask under an existing task.`,
|
|
52
54
|
parameters: {
|
|
53
55
|
type: 'object',
|
|
54
56
|
properties: {
|
|
@@ -69,6 +71,10 @@ Use this to break down complex work into trackable items.`,
|
|
|
69
71
|
type: 'string',
|
|
70
72
|
description: 'Parent task ID for subtasks',
|
|
71
73
|
},
|
|
74
|
+
// Note (task-333b): `parent_task_id` is accepted by execute() as a
|
|
75
|
+
// soft-compat alias for `parent_id` (absorbed from the former
|
|
76
|
+
// SpawnTask tool) but intentionally NOT advertised in the schema to
|
|
77
|
+
// avoid giving the LLM two live params for one field.
|
|
72
78
|
},
|
|
73
79
|
required: ['title'],
|
|
74
80
|
},
|
|
@@ -78,9 +84,17 @@ Use this to break down complex work into trackable items.`,
|
|
|
78
84
|
const err = requireStore();
|
|
79
85
|
if (err) return err;
|
|
80
86
|
|
|
81
|
-
const { title, description, priority = 'medium', parent_id } = input;
|
|
87
|
+
const { title, description, priority = 'medium', parent_id, parent_task_id } = input;
|
|
82
88
|
if (!title) return JSON.stringify({ error: 'title is required' });
|
|
83
89
|
|
|
90
|
+
// task-333b: accept either `parent_id` (original TaskCreate field) or
|
|
91
|
+
// `parent_task_id` (the former SpawnTask field). When both are present,
|
|
92
|
+
// parent_id wins.
|
|
93
|
+
const parentId = parent_id || parent_task_id || null;
|
|
94
|
+
if (parentId && !taskStore.get(parentId)) {
|
|
95
|
+
return JSON.stringify({ error: `Parent task not found: ${parentId}` });
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
const id = `task-${randomUUID().slice(0, 8)}`;
|
|
85
99
|
const task = {
|
|
86
100
|
id,
|
|
@@ -88,7 +102,8 @@ Use this to break down complex work into trackable items.`,
|
|
|
88
102
|
description: description || '',
|
|
89
103
|
priority,
|
|
90
104
|
status: 'pending',
|
|
91
|
-
parentId
|
|
105
|
+
parentId,
|
|
106
|
+
parentTaskId: parentId, // design §5 canonical field
|
|
92
107
|
createdAt: Date.now(),
|
|
93
108
|
updatedAt: Date.now(),
|
|
94
109
|
};
|
|
@@ -97,8 +112,10 @@ Use this to break down complex work into trackable items.`,
|
|
|
97
112
|
|
|
98
113
|
return JSON.stringify({
|
|
99
114
|
success: true,
|
|
100
|
-
task: { id, title, priority, status: 'pending' },
|
|
101
|
-
message:
|
|
115
|
+
task: { id, title, priority, status: 'pending', parentTaskId: parentId },
|
|
116
|
+
message: parentId
|
|
117
|
+
? `Subtask created: ${title} (${id}) under ${parentId}`
|
|
118
|
+
: `Task created: ${title} (${id})`,
|
|
102
119
|
});
|
|
103
120
|
},
|
|
104
121
|
});
|
|
@@ -193,20 +193,24 @@ the same thread.`,
|
|
|
193
193
|
},
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
// ─── SpawnTask (
|
|
196
|
+
// ─── SpawnTask (deprecated alias — task-333b) ──────────────
|
|
197
|
+
//
|
|
198
|
+
// task-333b folded SpawnTask into TaskCreate (see task-tools.js). Kept
|
|
199
|
+
// registered for one release as a deprecated alias per PM constraint:
|
|
200
|
+
// LLM calls still resolve, but a one-time console.warn nudges migration.
|
|
201
|
+
// Prefer TaskCreate with `parent_task_id` for all new call sites.
|
|
202
|
+
|
|
203
|
+
const _spawnTaskWarned = { v: false };
|
|
204
|
+
function warnSpawnTaskDeprecated() {
|
|
205
|
+
if (_spawnTaskWarned.v) return;
|
|
206
|
+
_spawnTaskWarned.v = true;
|
|
207
|
+
// eslint-disable-next-line no-console
|
|
208
|
+
console.warn('[deprecated] SpawnTask → TaskCreate. Pass parent_task_id to TaskCreate for subtasks.');
|
|
209
|
+
}
|
|
197
210
|
|
|
198
|
-
/**
|
|
199
|
-
* SpawnTask replaces both the old SpawnTask and the separate SpawnSubtask:
|
|
200
|
-
* pass `parent_task_id` when you want a subtask, omit it for top-level.
|
|
201
|
-
* Per prev-1 rework note: eliminating two tools with the same effect.
|
|
202
|
-
*/
|
|
203
211
|
export const spawnTask = defineTool({
|
|
204
212
|
name: 'SpawnTask',
|
|
205
|
-
description: `
|
|
206
|
-
|
|
207
|
-
When parent_task_id is omitted → top-level task.
|
|
208
|
-
When parent_task_id is provided → subtask under that parent (parent must exist).
|
|
209
|
-
This replaces the deprecated SpawnSubtask tool.`,
|
|
213
|
+
description: `DEPRECATED — use TaskCreate with parent_id instead. Retained as a thin alias for backwards compatibility; delegates to the same task store. When parent_task_id is omitted this behaves like TaskCreate; when provided it creates a subtask under that parent (parent must exist). Removal target: v0.2.0.`,
|
|
210
214
|
parameters: {
|
|
211
215
|
type: 'object',
|
|
212
216
|
properties: {
|
|
@@ -226,6 +230,7 @@ This replaces the deprecated SpawnSubtask tool.`,
|
|
|
226
230
|
isConcurrencySafe: () => false,
|
|
227
231
|
isReadOnly: () => false,
|
|
228
232
|
async execute(input) {
|
|
233
|
+
warnSpawnTaskDeprecated();
|
|
229
234
|
const store = getTaskStore();
|
|
230
235
|
if (!store) {
|
|
231
236
|
return JSON.stringify({ error: 'Task store not initialized. Session may still be loading.' });
|
|
@@ -1,31 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* view-image.js —
|
|
2
|
+
* view-image.js — Load a local image file into the LLM's context.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* task-333b PR-B: upgraded from metadata-only stub to a real multimodal
|
|
5
|
+
* loader. Returns a tool result that includes:
|
|
6
|
+
* - `image`: a base64-encoded data URI suitable for embedding into an
|
|
7
|
+
* LLM image content block (OpenAI / Anthropic style)
|
|
8
|
+
* - `media_type`: the canonical MIME (image/png, image/jpeg, ...)
|
|
9
|
+
* - `format`, `width`, `height`, `size`, `sizeFormatted`, `path`
|
|
10
|
+
*
|
|
11
|
+
* Safety rules (per PM 乔布斯 PR-B spec + prev-3 product review):
|
|
12
|
+
* - Path safety: no `..`, no absolute paths escaping cwd unless the
|
|
13
|
+
* resolved path lives under ctx.imageAllowlist[] (absolute dirs
|
|
14
|
+
* provided by the host).
|
|
15
|
+
* - Size cap: configurable via ctx.maxImageBytes (default 20 MiB).
|
|
16
|
+
* Larger files are rejected with a self-correcting error message
|
|
17
|
+
* that nudges resize/crop or config.json tuning.
|
|
18
|
+
* - MIME whitelist: png / jpeg / gif / webp / jfif. SVG / BMP / ICO /
|
|
19
|
+
* TIFF are intentionally excluded — they either aren't multimodal-
|
|
20
|
+
* LLM-safe (SVG = embedded script surface) or aren't supported by
|
|
21
|
+
* the mainstream vision endpoints.
|
|
22
|
+
* - HEIC is special-cased: we cannot decode it server-side, but the
|
|
23
|
+
* error nudges the user to convert via `sips -s format jpeg` (mac)
|
|
24
|
+
* instead of a generic "Unsupported format".
|
|
6
25
|
*/
|
|
7
26
|
|
|
8
27
|
import { defineTool } from './types.js';
|
|
9
28
|
import { stat, readFile } from 'fs/promises';
|
|
10
29
|
import { existsSync } from 'fs';
|
|
11
|
-
import { resolve, extname } from 'path';
|
|
30
|
+
import { resolve, extname, isAbsolute, relative } from 'path';
|
|
12
31
|
|
|
13
|
-
/**
|
|
14
|
-
const
|
|
32
|
+
/** Default max image size in bytes (20 MiB). Override via ctx.maxImageBytes. */
|
|
33
|
+
const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
15
34
|
|
|
16
35
|
/**
|
|
17
|
-
*
|
|
36
|
+
* Extension → canonical MIME type.
|
|
37
|
+
* The keys are the whitelist; anything else is rejected.
|
|
38
|
+
* `.jfif` (common Windows paste extension) maps to image/jpeg.
|
|
39
|
+
*/
|
|
40
|
+
const EXT_TO_MIME = Object.freeze({
|
|
41
|
+
'.png': 'image/png',
|
|
42
|
+
'.jpg': 'image/jpeg',
|
|
43
|
+
'.jpeg': 'image/jpeg',
|
|
44
|
+
'.jfif': 'image/jpeg',
|
|
45
|
+
'.gif': 'image/gif',
|
|
46
|
+
'.webp': 'image/webp',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const ALLOWED_EXTS = Object.keys(EXT_TO_MIME);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse basic image dimensions from header bytes. Best-effort — returns
|
|
53
|
+
* null if the file is too short or the format isn't one we parse.
|
|
54
|
+
*
|
|
55
|
+
* @param {Buffer} buffer
|
|
56
|
+
* @param {string} ext — lowercase extension including the dot
|
|
18
57
|
*/
|
|
19
58
|
function parseImageDimensions(buffer, ext) {
|
|
20
59
|
try {
|
|
21
|
-
if (ext === '.png') {
|
|
60
|
+
if (ext === '.png' && buffer.length >= 24) {
|
|
22
61
|
// PNG: width at offset 16, height at 20 (big-endian 32-bit)
|
|
23
|
-
|
|
24
|
-
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
25
|
-
}
|
|
62
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
26
63
|
}
|
|
27
|
-
if (ext === '.jpg' || ext === '.jpeg') {
|
|
28
|
-
// JPEG: scan for SOF0
|
|
64
|
+
if ((ext === '.jpg' || ext === '.jpeg' || ext === '.jfif') && buffer.length > 10) {
|
|
65
|
+
// JPEG: scan for SOF0 (0xFFC0) / SOF2 (0xFFC2) marker
|
|
29
66
|
for (let i = 0; i < buffer.length - 9; i++) {
|
|
30
67
|
if (buffer[i] === 0xFF && (buffer[i + 1] === 0xC0 || buffer[i + 1] === 0xC2)) {
|
|
31
68
|
return {
|
|
@@ -35,30 +72,117 @@ function parseImageDimensions(buffer, ext) {
|
|
|
35
72
|
}
|
|
36
73
|
}
|
|
37
74
|
}
|
|
38
|
-
if (ext === '.gif') {
|
|
75
|
+
if (ext === '.gif' && buffer.length >= 10) {
|
|
39
76
|
// GIF: width at offset 6, height at 8 (little-endian 16-bit)
|
|
40
|
-
|
|
41
|
-
|
|
77
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
|
|
78
|
+
}
|
|
79
|
+
if (ext === '.webp' && buffer.length >= 30) {
|
|
80
|
+
// WEBP: RIFF...WEBP...VP8(L|X| ). Three common sub-chunks.
|
|
81
|
+
if (buffer.slice(0, 4).toString('ascii') === 'RIFF' &&
|
|
82
|
+
buffer.slice(8, 12).toString('ascii') === 'WEBP') {
|
|
83
|
+
const fourcc = buffer.slice(12, 16).toString('ascii');
|
|
84
|
+
if (fourcc === 'VP8 ' && buffer.length >= 30) {
|
|
85
|
+
// Lossy: width/height at 26/28 as 14-bit LE (mask 0x3FFF)
|
|
86
|
+
return {
|
|
87
|
+
width: buffer.readUInt16LE(26) & 0x3FFF,
|
|
88
|
+
height: buffer.readUInt16LE(28) & 0x3FFF,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (fourcc === 'VP8L' && buffer.length >= 25) {
|
|
92
|
+
// Lossless: packed 14+14 bits at offset 21
|
|
93
|
+
const b0 = buffer[21], b1 = buffer[22], b2 = buffer[23], b3 = buffer[24];
|
|
94
|
+
const width = 1 + (((b1 & 0x3F) << 8) | b0);
|
|
95
|
+
const height = 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6));
|
|
96
|
+
return { width, height };
|
|
97
|
+
}
|
|
98
|
+
if (fourcc === 'VP8X' && buffer.length >= 30) {
|
|
99
|
+
// Extended: 24-bit LE widths/heights at 24/27, stored as (n-1)
|
|
100
|
+
const width = 1 + (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16));
|
|
101
|
+
const height = 1 + (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16));
|
|
102
|
+
return { width, height };
|
|
103
|
+
}
|
|
42
104
|
}
|
|
43
105
|
}
|
|
44
106
|
} catch {
|
|
45
|
-
// Dimension parsing is best-effort
|
|
107
|
+
// Dimension parsing is best-effort only.
|
|
46
108
|
}
|
|
47
109
|
return null;
|
|
48
110
|
}
|
|
49
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Check whether `absPath` is allowed given a project `cwd` and an optional
|
|
114
|
+
* allowlist of absolute directories. Returns `null` on success, or an object
|
|
115
|
+
* `{ kind, message }` describing the failure. The `kind` field lets callers
|
|
116
|
+
* tailor the error text (see prev-3 P2: distinguish "absolute path outside
|
|
117
|
+
* project" from "relative path containing ..").
|
|
118
|
+
*/
|
|
119
|
+
function checkPathAllowed(absPath, cwd, allowlist) {
|
|
120
|
+
// Reject if the resolved path lives inside the project (good).
|
|
121
|
+
const relToCwd = relative(cwd, absPath);
|
|
122
|
+
const insideCwd = relToCwd && !relToCwd.startsWith('..') && !isAbsolute(relToCwd);
|
|
123
|
+
if (insideCwd) return null;
|
|
124
|
+
|
|
125
|
+
// Otherwise must match an allowlist entry.
|
|
126
|
+
if (Array.isArray(allowlist) && allowlist.length > 0) {
|
|
127
|
+
for (const dir of allowlist) {
|
|
128
|
+
if (typeof dir !== 'string' || !isAbsolute(dir)) continue;
|
|
129
|
+
const rel = relative(dir, absPath);
|
|
130
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
kind: 'path_outside',
|
|
136
|
+
message:
|
|
137
|
+
'Path is outside the project directory and not on the image allowlist. ' +
|
|
138
|
+
'Either move the file into the project, or ask the user to add its parent ' +
|
|
139
|
+
'directory to ctx.imageAllowlist (set via ~/.yeaft/config.json imageAllowlist[]).',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatBytes(n) {
|
|
144
|
+
if (n < 1024) return `${n}B`;
|
|
145
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
146
|
+
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
147
|
+
}
|
|
148
|
+
|
|
50
149
|
export default defineTool({
|
|
51
150
|
name: 'ViewImage',
|
|
52
|
-
description: `
|
|
151
|
+
description: `Load a local image file and attach it to the conversation so the LLM can see it.
|
|
152
|
+
|
|
153
|
+
Returns a base64 data URI (\`image\` field) plus metadata (format, dimensions,
|
|
154
|
+
size). The caller/bridge is responsible for turning the data URI into the
|
|
155
|
+
provider-specific image content block.
|
|
156
|
+
|
|
157
|
+
When to call:
|
|
158
|
+
- User references a local image path (screenshot, design, log/chart) and
|
|
159
|
+
asks you to read, analyse, or describe it.
|
|
160
|
+
- User says "look at this file" / "check the screenshot at ..." / "what's
|
|
161
|
+
in docs/assets/arch.png?".
|
|
162
|
+
|
|
163
|
+
When NOT to call:
|
|
164
|
+
- The image is already attached to the current message (the host has
|
|
165
|
+
already uploaded it — you can see it without this tool).
|
|
166
|
+
- The image is a remote URL (http/https). ViewImage only reads local
|
|
167
|
+
files; use a fetch-style tool for URLs.
|
|
168
|
+
- You only need the file's existence / mtime / size — use Read or a
|
|
169
|
+
filesystem tool instead; ViewImage loads the full bytes into memory.
|
|
53
170
|
|
|
54
|
-
|
|
55
|
-
|
|
171
|
+
Path examples:
|
|
172
|
+
- Relative (resolved against project cwd): "./screenshots/bug.png",
|
|
173
|
+
"docs/assets/arch.png"
|
|
174
|
+
- Absolute inside an allowlisted dir: "/home/user/Downloads/error.png"
|
|
175
|
+
(only works when the host added that dir to ctx.imageAllowlist)
|
|
176
|
+
|
|
177
|
+
Supported formats: PNG, JPEG (.jpg/.jpeg/.jfif), GIF, WebP.
|
|
178
|
+
Max size: 20 MiB by default (configurable via ctx.maxImageBytes).
|
|
179
|
+
Path must live under the project directory or an explicit host allowlist.`,
|
|
56
180
|
parameters: {
|
|
57
181
|
type: 'object',
|
|
58
182
|
properties: {
|
|
59
183
|
file_path: {
|
|
60
184
|
type: 'string',
|
|
61
|
-
description: 'Path to the image file',
|
|
185
|
+
description: 'Path to the image file. Relative paths are resolved against the project cwd.',
|
|
62
186
|
},
|
|
63
187
|
},
|
|
64
188
|
required: ['file_path'],
|
|
@@ -66,51 +190,126 @@ Supports PNG, JPEG, GIF, BMP, WebP, SVG, and ICO.`,
|
|
|
66
190
|
isConcurrencySafe: () => true,
|
|
67
191
|
isReadOnly: () => true,
|
|
68
192
|
async execute(input, ctx) {
|
|
69
|
-
const { file_path } = input;
|
|
70
|
-
if (!file_path
|
|
193
|
+
const { file_path } = input || {};
|
|
194
|
+
if (!file_path || typeof file_path !== 'string') {
|
|
195
|
+
return JSON.stringify({ error: 'file_path is required and must be a string' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Reject `..` segments explicitly before path resolution — catches the
|
|
199
|
+
// cases where resolve() might still land inside cwd by accident. This
|
|
200
|
+
// also gives LLMs a self-correcting error ("don't use ../") distinct
|
|
201
|
+
// from the "path outside project" message for absolute paths.
|
|
202
|
+
if (file_path.split(/[/\\]/).some(seg => seg === '..')) {
|
|
203
|
+
return JSON.stringify({
|
|
204
|
+
error:
|
|
205
|
+
'file_path must not contain `..` segments. Use a path relative to the ' +
|
|
206
|
+
'project (e.g. "docs/assets/foo.png") or an absolute path under an ' +
|
|
207
|
+
'allowlisted directory.',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
71
210
|
|
|
72
211
|
const cwd = ctx?.cwd || process.cwd();
|
|
212
|
+
const allowlist = Array.isArray(ctx?.imageAllowlist) ? ctx.imageAllowlist : [];
|
|
213
|
+
// Size cap: ctx.maxImageBytes (host-injected from config.json) wins,
|
|
214
|
+
// falling back to 20 MiB. A non-finite / non-positive override is ignored.
|
|
215
|
+
const maxBytes =
|
|
216
|
+
Number.isFinite(ctx?.maxImageBytes) && ctx.maxImageBytes > 0
|
|
217
|
+
? Math.floor(ctx.maxImageBytes)
|
|
218
|
+
: DEFAULT_MAX_IMAGE_BYTES;
|
|
73
219
|
const absPath = resolve(cwd, file_path);
|
|
74
220
|
|
|
75
|
-
|
|
76
|
-
|
|
221
|
+
const pathErr = checkPathAllowed(absPath, cwd, allowlist);
|
|
222
|
+
if (pathErr) {
|
|
223
|
+
// prev-3 P2: split "absolute outside project" from "relative ..".
|
|
224
|
+
// The `..` case is already handled above, so anything reaching here
|
|
225
|
+
// is either an absolute path outside cwd/allowlist or a relative
|
|
226
|
+
// path that resolve() pushed outside cwd (rare). Either way, the
|
|
227
|
+
// host-level fix is the same, so we keep one nudge message.
|
|
228
|
+
const isAbs = isAbsolute(file_path);
|
|
229
|
+
const hint = isAbs
|
|
230
|
+
? 'Absolute path is outside the project directory. '
|
|
231
|
+
: 'Resolved path is outside the project directory. ';
|
|
232
|
+
return JSON.stringify({
|
|
233
|
+
error: hint + pathErr.message,
|
|
234
|
+
path: absPath,
|
|
235
|
+
});
|
|
77
236
|
}
|
|
78
237
|
|
|
79
238
|
const ext = extname(absPath).toLowerCase();
|
|
80
|
-
|
|
81
|
-
|
|
239
|
+
// HEIC special-case: iPhone screenshots default to HEIC and silently
|
|
240
|
+
// fail today. Give users a concrete one-liner to fix it instead of a
|
|
241
|
+
// generic "Unsupported format".
|
|
242
|
+
if (ext === '.heic' || ext === '.heif') {
|
|
243
|
+
return JSON.stringify({
|
|
244
|
+
error:
|
|
245
|
+
'HEIC images need to be converted to JPEG first. ' +
|
|
246
|
+
'Use `sips -s format jpeg <file> --out <file>.jpg` on macOS ' +
|
|
247
|
+
'(or an equivalent tool like ImageMagick on Linux/Windows), then retry.',
|
|
248
|
+
format: ext.slice(1).toUpperCase(),
|
|
249
|
+
supported: ALLOWED_EXTS,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (!(ext in EXT_TO_MIME)) {
|
|
253
|
+
return JSON.stringify({
|
|
254
|
+
error: `Unsupported image format: ${ext || '(none)'}`,
|
|
255
|
+
supported: ALLOWED_EXTS,
|
|
256
|
+
});
|
|
82
257
|
}
|
|
83
258
|
|
|
259
|
+
if (!existsSync(absPath)) {
|
|
260
|
+
return JSON.stringify({ error: `Image not found: ${absPath}` });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let fileStat;
|
|
84
264
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
265
|
+
fileStat = await stat(absPath);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
return JSON.stringify({ error: `Failed to stat image: ${err.message}` });
|
|
268
|
+
}
|
|
87
269
|
|
|
88
|
-
|
|
270
|
+
if (!fileStat.isFile()) {
|
|
271
|
+
return JSON.stringify({ error: 'file_path does not point to a regular file' });
|
|
272
|
+
}
|
|
89
273
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
274
|
+
if (fileStat.size > maxBytes) {
|
|
275
|
+
return JSON.stringify({
|
|
276
|
+
error:
|
|
277
|
+
`Image exceeds ${formatBytes(maxBytes)} (${formatBytes(fileStat.size)} actual). ` +
|
|
278
|
+
`Reduce image size (resize/crop), or set \`maxImageBytes\` in ` +
|
|
279
|
+
`~/.yeaft/config.json if your LLM supports more.`,
|
|
93
280
|
size: fileStat.size,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
modified: fileStat.mtime.toISOString(),
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
if (dimensions) {
|
|
101
|
-
result.width = dimensions.width;
|
|
102
|
-
result.height = dimensions.height;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// For SVG, include a text preview
|
|
106
|
-
if (ext === '.svg') {
|
|
107
|
-
const svgText = buffer.toString('utf-8');
|
|
108
|
-
result.preview = svgText.slice(0, 500);
|
|
109
|
-
}
|
|
281
|
+
maxSize: maxBytes,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
110
284
|
|
|
111
|
-
|
|
285
|
+
let buffer;
|
|
286
|
+
try {
|
|
287
|
+
buffer = await readFile(absPath);
|
|
112
288
|
} catch (err) {
|
|
113
289
|
return JSON.stringify({ error: `Failed to read image: ${err.message}` });
|
|
114
290
|
}
|
|
291
|
+
|
|
292
|
+
const mediaType = EXT_TO_MIME[ext];
|
|
293
|
+
const base64 = buffer.toString('base64');
|
|
294
|
+
const dataUri = `data:${mediaType};base64,${base64}`;
|
|
295
|
+
const dimensions = parseImageDimensions(buffer, ext);
|
|
296
|
+
|
|
297
|
+
const result = {
|
|
298
|
+
path: absPath,
|
|
299
|
+
format: ext.slice(1).toUpperCase() === 'JPG' ? 'JPEG' : ext.slice(1).toUpperCase(),
|
|
300
|
+
media_type: mediaType,
|
|
301
|
+
size: fileStat.size,
|
|
302
|
+
sizeFormatted: formatBytes(fileStat.size),
|
|
303
|
+
modified: fileStat.mtime.toISOString(),
|
|
304
|
+
image: dataUri,
|
|
305
|
+
};
|
|
306
|
+
// Normalise .jfif to JPEG in display format too, for consistency.
|
|
307
|
+
if (ext === '.jfif') result.format = 'JPEG';
|
|
308
|
+
if (dimensions) {
|
|
309
|
+
result.width = dimensions.width;
|
|
310
|
+
result.height = dimensions.height;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return JSON.stringify(result, null, 2);
|
|
115
314
|
},
|
|
116
315
|
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tool-search.js — Search available tools by name or description.
|
|
3
|
-
*
|
|
4
|
-
* task-311: chat/work mode was removed in task-297; this tool no longer
|
|
5
|
-
* accepts or reports a `modes` filter. Results come back as plain
|
|
6
|
-
* { name, description } pairs.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { defineTool } from './types.js';
|
|
10
|
-
|
|
11
|
-
export default defineTool({
|
|
12
|
-
name: 'ToolSearch',
|
|
13
|
-
description: `Search available tools by name or description keyword.
|
|
14
|
-
|
|
15
|
-
Use when you're unsure which tool to use for a task.
|
|
16
|
-
Returns matching tools with their descriptions.`,
|
|
17
|
-
parameters: {
|
|
18
|
-
type: 'object',
|
|
19
|
-
properties: {
|
|
20
|
-
query: {
|
|
21
|
-
type: 'string',
|
|
22
|
-
description: 'Search keyword to match against tool names and descriptions',
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
required: ['query'],
|
|
26
|
-
},
|
|
27
|
-
isConcurrencySafe: () => true,
|
|
28
|
-
isReadOnly: () => true,
|
|
29
|
-
async execute(input, _ctx) {
|
|
30
|
-
const { query } = input;
|
|
31
|
-
if (!query) return JSON.stringify({ error: 'query is required' });
|
|
32
|
-
|
|
33
|
-
const lowerQuery = query.toLowerCase();
|
|
34
|
-
|
|
35
|
-
// Self-referential catalogue of tools available to the engine.
|
|
36
|
-
const toolList = [
|
|
37
|
-
{ name: 'AskUser', description: 'Ask the user a question' },
|
|
38
|
-
{ name: 'MemoryRead', description: 'Read from memory system' },
|
|
39
|
-
{ name: 'MemoryWrite', description: 'Write to memory system' },
|
|
40
|
-
{ name: 'MemorySearch', description: 'Search memory entries' },
|
|
41
|
-
{ name: 'WebSearch', description: 'Search the web' },
|
|
42
|
-
{ name: 'WebFetch', description: 'Fetch web page content' },
|
|
43
|
-
{ name: 'HistorySearch', description: 'Search conversation history' },
|
|
44
|
-
{ name: 'Bash', description: 'Execute shell commands' },
|
|
45
|
-
{ name: 'FileRead', description: 'Read file with line numbers' },
|
|
46
|
-
{ name: 'FileWrite', description: 'Write/create files' },
|
|
47
|
-
{ name: 'FileEdit', description: 'Surgical string replacement in files' },
|
|
48
|
-
{ name: 'Glob', description: 'Find files by pattern' },
|
|
49
|
-
{ name: 'Grep', description: 'Search file contents' },
|
|
50
|
-
{ name: 'ListDir', description: 'List directory contents' },
|
|
51
|
-
{ name: 'ApplyPatch', description: 'Apply unified diff patches' },
|
|
52
|
-
{ name: 'Agent', description: 'Create sub-agents' },
|
|
53
|
-
{ name: 'SendMessage', description: 'Send message to sub-agent' },
|
|
54
|
-
{ name: 'WaitAgent', description: 'Wait for sub-agent result' },
|
|
55
|
-
{ name: 'CloseAgent', description: 'Close a sub-agent' },
|
|
56
|
-
{ name: 'ListAgents', description: 'List all sub-agents' },
|
|
57
|
-
{ name: 'TaskCreate', description: 'Create a task' },
|
|
58
|
-
{ name: 'TaskUpdate', description: 'Update task status' },
|
|
59
|
-
{ name: 'TaskList', description: 'List all tasks' },
|
|
60
|
-
{ name: 'TaskGet', description: 'Get task details' },
|
|
61
|
-
{ name: 'FollowupTask', description: 'Create follow-up task' },
|
|
62
|
-
{ name: 'UpdatePlan', description: 'View/update execution plan' },
|
|
63
|
-
{ name: 'JsRepl', description: 'JavaScript REPL evaluation' },
|
|
64
|
-
{ name: 'JsReplReset', description: 'Reset REPL state' },
|
|
65
|
-
{ name: 'NotebookEdit', description: 'Edit Jupyter notebooks' },
|
|
66
|
-
{ name: 'ImageGeneration', description: 'Generate images from text' },
|
|
67
|
-
{ name: 'ViewImage', description: 'View image metadata' },
|
|
68
|
-
{ name: 'RequestPermissions', description: 'Request dangerous operation permissions' },
|
|
69
|
-
{ name: 'WriteStdin', description: 'Write to running process stdin' },
|
|
70
|
-
{ name: 'Skill', description: 'Load skills from library' },
|
|
71
|
-
{ name: 'EnterWorktree', description: 'Create git worktree' },
|
|
72
|
-
{ name: 'ExitWorktree', description: 'Exit git worktree' },
|
|
73
|
-
{ name: 'mcp_list_tools', description: 'List MCP server tools' },
|
|
74
|
-
{ name: 'mcp_call_tool', description: 'Call MCP server tool' },
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
const results = toolList.filter(t =>
|
|
78
|
-
t.name.toLowerCase().includes(lowerQuery) ||
|
|
79
|
-
t.description.toLowerCase().includes(lowerQuery)
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
return JSON.stringify({
|
|
83
|
-
results,
|
|
84
|
-
totalResults: results.length,
|
|
85
|
-
query,
|
|
86
|
-
}, null, 2);
|
|
87
|
-
},
|
|
88
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* write-stdin.js — Write data to a running process's stdin.
|
|
3
|
-
*
|
|
4
|
-
* Used in conjunction with Bash for processes that need interactive input.
|
|
5
|
-
* Currently returns a guidance message since Bash tool uses 'ignore' for stdin.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { defineTool } from './types.js';
|
|
9
|
-
|
|
10
|
-
export default defineTool({
|
|
11
|
-
name: 'WriteStdin',
|
|
12
|
-
description: `Write data to a running process's standard input.
|
|
13
|
-
|
|
14
|
-
This tool is intended for sending input to interactive processes.
|
|
15
|
-
Since the Bash tool runs commands non-interactively, this is primarily
|
|
16
|
-
useful with the terminal system or long-running processes.
|
|
17
|
-
|
|
18
|
-
Note: For most use cases, pipe input via Bash: echo "input" | command`,
|
|
19
|
-
parameters: {
|
|
20
|
-
type: 'object',
|
|
21
|
-
properties: {
|
|
22
|
-
process_id: {
|
|
23
|
-
type: 'string',
|
|
24
|
-
description: 'Process identifier or terminal ID',
|
|
25
|
-
},
|
|
26
|
-
data: {
|
|
27
|
-
type: 'string',
|
|
28
|
-
description: 'Data to write to stdin',
|
|
29
|
-
},
|
|
30
|
-
newline: {
|
|
31
|
-
type: 'boolean',
|
|
32
|
-
description: 'Append newline after data (default: true)',
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
required: ['data'],
|
|
36
|
-
},
|
|
37
|
-
isConcurrencySafe: () => false,
|
|
38
|
-
isReadOnly: () => false,
|
|
39
|
-
async execute(input, ctx) {
|
|
40
|
-
const { process_id, data, newline = true } = input;
|
|
41
|
-
if (!data && data !== '') return JSON.stringify({ error: 'data is required' });
|
|
42
|
-
|
|
43
|
-
// The Bash tool uses 'ignore' for stdin, so direct stdin writing
|
|
44
|
-
// is only possible through the terminal system.
|
|
45
|
-
// For most interactive needs, recommend using pipe syntax.
|
|
46
|
-
return JSON.stringify({
|
|
47
|
-
hint: 'The Bash tool does not support interactive stdin. Use pipe syntax instead:',
|
|
48
|
-
example: `echo "${data}" | your_command`,
|
|
49
|
-
alternativeBash: `printf '%s\\n' '${data.replace(/'/g, "'\\''")}' | your_command`,
|
|
50
|
-
message: 'For interactive processes, use the terminal system (not the AI tool system).',
|
|
51
|
-
});
|
|
52
|
-
},
|
|
53
|
-
});
|