@hncode/hncode 0.3.6 → 0.4.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/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
package/src/tools/edit.js
CHANGED
|
@@ -3,9 +3,24 @@
|
|
|
3
3
|
// so edits stay token-cheap (unlike kimi-code-cli's whole-file Edit).
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { resolvePath, ensureDir, checkpointBeforeWrite } from './utils.js';
|
|
7
8
|
import { hashStr, normalizeText } from './read.js';
|
|
8
9
|
|
|
10
|
+
// Typographic quotes -> straight quotes. A model almost always emits ASCII
|
|
11
|
+
// quotes, but docs/blog/markdown often use curly ones; without this the edit
|
|
12
|
+
// fails with "old_string not found" even though the text is visibly there.
|
|
13
|
+
// Mirrors Claude Code's normalizeQuotes.
|
|
14
|
+
const CURLY = [
|
|
15
|
+
['\u2018', "'"], ['\u2019', "'"], // ‘ ’
|
|
16
|
+
['\u201c', '"'], ['\u201d', '"'], // “ ”
|
|
17
|
+
];
|
|
18
|
+
export function normalizeQuotes(s) {
|
|
19
|
+
let out = String(s);
|
|
20
|
+
for (const [from, to] of CURLY) out = out.split(from).join(to);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
// Re-exported from read.js so the staleness guard has ONE definition of the
|
|
10
25
|
// region hash. Previously read.js and edit.js each had their own copy; any
|
|
11
26
|
// drift between them would silently disable staleness detection.
|
|
@@ -13,39 +28,61 @@ import { hashRegion as hashLines } from './read.js';
|
|
|
13
28
|
|
|
14
29
|
export const spec = {
|
|
15
30
|
name: 'Edit',
|
|
16
|
-
description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n - LINE-RANGE mode: path + start_line + end_line +
|
|
31
|
+
description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n - LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string. PRESERVE the exact indentation (spaces/tabs) of the lines you are replacing — copy it straight from the Read output, after its line-number prefix. PREFERRED — use it whenever you have Read the file and know the line numbers.\n - EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_string is an error, not an empty replacement (in line-range mode pass new_string; in substring mode pass old_string + new_string).',
|
|
17
32
|
parameters: {
|
|
18
33
|
type: 'object',
|
|
19
34
|
properties: {
|
|
20
35
|
path: { type: 'string', description: 'Path to the file to edit.' },
|
|
21
36
|
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
|
|
22
|
-
new_string: { type: 'string', description: 'Replacement text
|
|
37
|
+
new_string: { type: 'string', description: 'Replacement text. In SUBSTRING mode it replaces old_string. In LINE-RANGE mode it replaces lines [start_line, end_line]. In line-range mode PRESERVE the exact leading whitespace (indentation) from Read output; copy it exactly as it appears after the line-number prefix.' },
|
|
23
38
|
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
24
|
-
start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and
|
|
39
|
+
start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_string). Use this line-range mode first.' },
|
|
25
40
|
end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line).' },
|
|
26
|
-
new_content: { type: 'string', description: 'PREFERRED MODE. Replacement text for the line range [start_line, end_line]. Requires start_line and end_line.' },
|
|
27
41
|
},
|
|
28
|
-
// Either line-range mode (start_line+end_line+
|
|
42
|
+
// Either line-range mode (start_line+end_line+new_string) OR exact-substring
|
|
29
43
|
// mode (old_string+new_string).
|
|
30
44
|
// Line-range is the preferred route.
|
|
31
45
|
anyOf: [
|
|
32
|
-
{ required: ['path', 'start_line', 'end_line', '
|
|
46
|
+
{ required: ['path', 'start_line', 'end_line', 'new_string'] },
|
|
33
47
|
{ required: ['path', 'old_string', 'new_string'] },
|
|
34
48
|
],
|
|
35
49
|
},
|
|
36
50
|
async execute(args, ctx) {
|
|
51
|
+
// Validate BEFORE resolving the path. `resolvePath(undefined)` resolves to the
|
|
52
|
+
// workspace root rather than failing, so a truncated tool call (agent.js falls
|
|
53
|
+
// back to `{ raw, _rawLen }` when the streamed JSON will not parse) could
|
|
54
|
+
// target a DIRECTORY and produce a confusing EISDIR/ENOENT instead of "retry
|
|
55
|
+
// the call".
|
|
56
|
+
if (typeof args.path !== 'string' || !args.path.trim()) {
|
|
57
|
+
const truncated = args && args.raw
|
|
58
|
+
? ` The arguments did not parse as JSON${args._rawLen ? ` (${args._rawLen} characters received)` : ''} — the call was most likely truncated. Retry, and keep \`old_string\` / \`new_string\` small (edit in several passes rather than one large replacement).`
|
|
59
|
+
: '';
|
|
60
|
+
return `Error: \`path\` is required and must be a non-empty string.${truncated}`;
|
|
61
|
+
}
|
|
37
62
|
let p;
|
|
38
63
|
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
64
|
+
// A Jupyter notebook is JSON, not text: a snippet edit corrupts it. Point the
|
|
65
|
+
// model at the notebook-aware path instead (mirrors Claude Code errorCode 5).
|
|
66
|
+
if (String(p).toLowerCase().endsWith('.ipynb')) {
|
|
67
|
+
return `Error: ${args.path} is a Jupyter notebook. Its .ipynb is JSON — edit the cells through a notebook-aware tool (or edit the JSON carefully), not a plain text Edit.`;
|
|
68
|
+
}
|
|
39
69
|
let content;
|
|
40
|
-
try {
|
|
41
|
-
|
|
70
|
+
try {
|
|
71
|
+
content = fs.readFileSync(p, 'utf8');
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e && e.code === 'ENOENT') {
|
|
74
|
+
return `Error reading ${args.path}: file does not exist.${similarFileHint(p)}`;
|
|
75
|
+
}
|
|
76
|
+
return `Error reading ${args.path}: ${e.message}`;
|
|
77
|
+
}
|
|
78
|
+
const { start_line, end_line } = args;
|
|
42
79
|
// The two modes are MUTUALLY EXCLUSIVE: sending line-range AND substring
|
|
43
80
|
// parameters together is ambiguous — reject it rather than guessing which
|
|
44
81
|
// one the caller meant. Only one family may be present in `args`.
|
|
45
|
-
const hasRangeKeys = args.start_line != null || args.end_line != null
|
|
46
|
-
const hasSubKeys = args.old_string != null
|
|
82
|
+
const hasRangeKeys = args.start_line != null || args.end_line != null;
|
|
83
|
+
const hasSubKeys = args.old_string != null;
|
|
47
84
|
if (hasRangeKeys && hasSubKeys) {
|
|
48
|
-
return `Error: do not mix line-range mode and substring mode. Use EITHER (start_line/end_line/
|
|
85
|
+
return `Error: do not mix line-range mode and substring mode. Use EITHER (start_line/end_line/new_string) OR (old_string/new_string), never both.`;
|
|
49
86
|
}
|
|
50
87
|
|
|
51
88
|
// ---- LINE-RANGE mode ---------------------------------------------------
|
|
@@ -54,10 +91,21 @@ export const spec = {
|
|
|
54
91
|
// where spelling out the exact current text is awkward. It is fully
|
|
55
92
|
// self-contained: validate -> check the Read cache -> splice -> write ->
|
|
56
93
|
// refresh the cache -> return.
|
|
94
|
+
// ONE bound without the other is its own mistake and an easy one to make: the
|
|
95
|
+
// call then falls past this block and reports a missing old_string, which
|
|
96
|
+
// sends the model looking in the wrong place.
|
|
97
|
+
if ((start_line != null) !== (end_line != null)) {
|
|
98
|
+
return 'Error: line-range mode needs BOTH start_line and end_line.';
|
|
99
|
+
}
|
|
57
100
|
if (start_line != null && end_line != null) {
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
101
|
+
// Accept both spellings so old new_content callers keep working.
|
|
102
|
+
const new_string = args.new_string != null ? args.new_string : args.new_content;
|
|
103
|
+
// new_string is required — a missing one would put the literal string
|
|
104
|
+
// "undefined" in the file.
|
|
105
|
+
if (new_string == null) return `Error: new_string is required for line-range mode (pass an empty string to delete the lines).`;
|
|
106
|
+
if (typeof new_string !== 'string') {
|
|
107
|
+
return `Error: new_string must be a string (got ${new_string === null ? 'null' : typeof new_string}).`;
|
|
108
|
+
}
|
|
61
109
|
const start = Number(start_line) | 0, end = Number(end_line) | 0;
|
|
62
110
|
if (start < 1 || end < start) {
|
|
63
111
|
return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
|
|
@@ -73,16 +121,18 @@ export const spec = {
|
|
|
73
121
|
const stale = checkStaleRange(p, lines, start, end, ctx);
|
|
74
122
|
if (stale) return stale;
|
|
75
123
|
|
|
76
|
-
const inserted = normalizeText(
|
|
124
|
+
const inserted = normalizeText(new_string).split('\n');
|
|
77
125
|
// The lines being REPLACED, captured before the splice. The TUI renders an
|
|
78
126
|
// Edit's diff from the tool ARGUMENTS, and line-range mode has no
|
|
79
127
|
// `old_string` to diff against — so without reporting them here the edit
|
|
80
128
|
// drew NO diff at all (no +/- rows, no +N/-N counts). Reporting them lets
|
|
81
|
-
// the caller diff
|
|
129
|
+
// the caller diff the replaced region -> `new_string`.
|
|
82
130
|
const replacedText = lines.slice(start - 1, end).join('\n');
|
|
83
131
|
const out = [...lines.slice(0, start - 1), ...inserted, ...lines.slice(end)];
|
|
84
132
|
const normUpdated = out.join('\n');
|
|
85
133
|
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
134
|
+
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
135
|
+
checkpointBeforeWrite(p, ctx);
|
|
86
136
|
try {
|
|
87
137
|
ensureDir(p);
|
|
88
138
|
fs.writeFileSync(p, updated, 'utf8');
|
|
@@ -93,13 +143,20 @@ export const spec = {
|
|
|
93
143
|
// line-range edit shows the same +/- rows and +N/-N counts as a substring
|
|
94
144
|
// edit instead of nothing. `ctx.lastEditDiff` is consumed (and cleared) by
|
|
95
145
|
// the agent loop right after this tool returns.
|
|
96
|
-
if (ctx) ctx.lastEditDiff = { old: replacedText, new: normalizeText(
|
|
146
|
+
if (ctx) ctx.lastEditDiff = { old: replacedText, new: normalizeText(new_string), startLine: start };
|
|
97
147
|
return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${inserted.length} line${inserted.length === 1 ? '' : 's'}.`;
|
|
98
148
|
}
|
|
99
149
|
|
|
100
150
|
|
|
101
151
|
// ---- SUBSTRING mode (old_string -> new_string) --------------------------
|
|
102
152
|
let { old_string, new_string, replace_all = false } = args;
|
|
153
|
+
// Reaching here means neither mode was usable: line-range needs
|
|
154
|
+
// start_line+end_line (handled above) and substring needs old_string. When
|
|
155
|
+
// BOTH are absent, say so — "old_string must not be empty" sends the model
|
|
156
|
+
// hunting for a stray empty string it never sent.
|
|
157
|
+
if (old_string == null && new_string == null) {
|
|
158
|
+
return 'Error: no edit mode given. Pass EITHER (start_line, end_line, new_string) OR (old_string, new_string).';
|
|
159
|
+
}
|
|
103
160
|
if (old_string === '' || old_string == null) return `Error: old_string must not be empty.`;
|
|
104
161
|
if (new_string == null) return `Error: new_string is required for substring mode.`;
|
|
105
162
|
|
|
@@ -113,8 +170,32 @@ export const spec = {
|
|
|
113
170
|
const normOld = normalizeText(old_string);
|
|
114
171
|
const normNew = normalizeText(new_string);
|
|
115
172
|
|
|
116
|
-
|
|
117
|
-
|
|
173
|
+
let matchOld = normOld; // the string that will actually be matched/replaced
|
|
174
|
+
let matchNew = normNew;
|
|
175
|
+
let count = normContent.split(matchOld).length - 1;
|
|
176
|
+
if (count === 0) {
|
|
177
|
+
// Quote-normalized retry: the file may use curly quotes while the model sent
|
|
178
|
+
// straight ones. Match on the normalized forms, then replace the ACTUAL
|
|
179
|
+
// substring found in the file so typography is preserved.
|
|
180
|
+
// Quote-normalized retry: the file may use curly quotes while the model sent
|
|
181
|
+
// straight ones. Match on the normalized forms, then replace the ACTUAL
|
|
182
|
+
// substring found in the file so typography is preserved. normalizeQuotes is
|
|
183
|
+
// 1 char -> 1 char, so indices line up between the two strings.
|
|
184
|
+
const normFileQ = normalizeQuotes(normContent);
|
|
185
|
+
if (normFileQ !== normContent) {
|
|
186
|
+
const normOldQ = normalizeQuotes(normOld);
|
|
187
|
+
const idx = normFileQ.indexOf(normOldQ);
|
|
188
|
+
if (idx >= 0) {
|
|
189
|
+
matchOld = normContent.slice(idx, idx + normOldQ.length);
|
|
190
|
+
matchNew = normNew; // keep the model's new_string as written
|
|
191
|
+
count = normContent.split(matchOld).length - 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (count === 0) {
|
|
196
|
+
const near = nearestLineHint(normContent, normOld);
|
|
197
|
+
return `Error: old_string not found in ${args.path}.${near}`;
|
|
198
|
+
}
|
|
118
199
|
if (count > 1 && !replace_all) {
|
|
119
200
|
return `Error: old_string matched ${count} occurrences in ${args.path}. Use replace_all=true or make old_string unique.`;
|
|
120
201
|
}
|
|
@@ -123,13 +204,15 @@ export const spec = {
|
|
|
123
204
|
// We stored per-region + whole-file hashes in ctx.readPool when Read ran.
|
|
124
205
|
// If the file changed on disk since (external edit, stream, Ctrl-C side
|
|
125
206
|
// effect), reject so a snippet edit cannot silently corrupt the file. ---
|
|
126
|
-
const stale = checkStale(p, normContent,
|
|
207
|
+
const stale = checkStale(p, normContent, matchOld, ctx);
|
|
127
208
|
if (stale) return stale;
|
|
128
209
|
|
|
129
210
|
const normUpdated = replace_all
|
|
130
|
-
? normContent.replaceAll(
|
|
131
|
-
: normContent.replace(
|
|
211
|
+
? normContent.replaceAll(matchOld, matchNew)
|
|
212
|
+
: normContent.replace(matchOld, matchNew);
|
|
132
213
|
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
214
|
+
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
215
|
+
checkpointBeforeWrite(p, ctx);
|
|
133
216
|
try {
|
|
134
217
|
ensureDir(p);
|
|
135
218
|
fs.writeFileSync(p, updated, 'utf8');
|
|
@@ -147,11 +230,11 @@ export const spec = {
|
|
|
147
230
|
entry.lines = newLines.length;
|
|
148
231
|
// Locate the edit site in the NEW content: the first line of the new
|
|
149
232
|
// text if present, else fall back to the old target's location.
|
|
150
|
-
const anchor = String(
|
|
233
|
+
const anchor = String(matchNew).split('\n')[0];
|
|
151
234
|
let at = newLines.indexOf(anchor);
|
|
152
|
-
if (at < 0) at = newLines.indexOf(String(
|
|
235
|
+
if (at < 0) at = newLines.indexOf(String(matchOld).split('\n')[0]);
|
|
153
236
|
if (at >= 0) {
|
|
154
|
-
const newSpan = String(
|
|
237
|
+
const newSpan = String(matchNew).split('\n').length;
|
|
155
238
|
const start = at + 1;
|
|
156
239
|
const end = Math.min(newLines.length, at + Math.max(1, newSpan));
|
|
157
240
|
// Re-hash every surviving old region against the new content, and add
|
|
@@ -179,6 +262,73 @@ export const spec = {
|
|
|
179
262
|
},
|
|
180
263
|
};
|
|
181
264
|
|
|
265
|
+
// Suggest a near-miss filename when a path does not exist: same directory, an
|
|
266
|
+
// exact case-insensitive match, same stem with a different extension, or a
|
|
267
|
+
// prefix match. Saves the model a round-trip of guessing (Claude Code's
|
|
268
|
+
// "Did you mean X?").
|
|
269
|
+
function similarFileHint(missingPath) {
|
|
270
|
+
const dir = path.dirname(missingPath);
|
|
271
|
+
const base = path.basename(missingPath);
|
|
272
|
+
const stem = base.replace(/\.[^.]*$/, '').toLowerCase();
|
|
273
|
+
let entries;
|
|
274
|
+
try { entries = fs.readdirSync(dir); } catch { return ''; }
|
|
275
|
+
const lower = base.toLowerCase();
|
|
276
|
+
// Levenshtein distance, capped — enough to catch a typo (confg -> config).
|
|
277
|
+
const lev = (a, b) => {
|
|
278
|
+
const m = a.length, n = b.length;
|
|
279
|
+
if (Math.abs(m - n) > 2) return 3;
|
|
280
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
281
|
+
for (let i = 1; i <= m; i++) {
|
|
282
|
+
const cur = [i];
|
|
283
|
+
for (let j = 1; j <= n; j++) {
|
|
284
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
285
|
+
}
|
|
286
|
+
prev = cur;
|
|
287
|
+
}
|
|
288
|
+
return prev[n];
|
|
289
|
+
};
|
|
290
|
+
let cand = entries.find((e) => e.toLowerCase() === lower);
|
|
291
|
+
if (!cand) {
|
|
292
|
+
let best = null, bestD = 3;
|
|
293
|
+
for (const e of entries) {
|
|
294
|
+
const d = lev(stem, e.replace(/\.[^.]*$/, '').toLowerCase());
|
|
295
|
+
if (d < bestD) { bestD = d; best = e; }
|
|
296
|
+
}
|
|
297
|
+
cand = best;
|
|
298
|
+
}
|
|
299
|
+
if (cand && cand !== base) return ` Did you mean "${path.join(dir, cand)}"?`;
|
|
300
|
+
return '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// When old_string is not found, look for the closest line in the file and name
|
|
304
|
+
// it, so the model can see what the text actually is instead of guessing.
|
|
305
|
+
function nearestLineHint(content, oldStr) {
|
|
306
|
+
const want = String(oldStr).split('\n')[0].trim();
|
|
307
|
+
if (!want || want.length < 3) return '';
|
|
308
|
+
const lines = String(content).split('\n');
|
|
309
|
+
// Character bigrams: robust to small differences and cheap on long lines.
|
|
310
|
+
const grams = (s) => {
|
|
311
|
+
const set = new Set();
|
|
312
|
+
for (let i = 0; i + 1 < s.length; i++) set.add(s.slice(i, i + 2));
|
|
313
|
+
return set;
|
|
314
|
+
};
|
|
315
|
+
const wantGrams = grams(want);
|
|
316
|
+
if (!wantGrams.size) return '';
|
|
317
|
+
let best = -1, bestScore = 0;
|
|
318
|
+
for (let i = 0; i < lines.length; i++) {
|
|
319
|
+
const g = grams(lines[i].trim());
|
|
320
|
+
if (!g.size) continue;
|
|
321
|
+
let hit = 0;
|
|
322
|
+
for (const t of wantGrams) if (g.has(t)) hit++;
|
|
323
|
+
const score = hit / wantGrams.size;
|
|
324
|
+
if (score > bestScore) { bestScore = score; best = i; }
|
|
325
|
+
}
|
|
326
|
+
if (best >= 0 && bestScore >= 0.5) {
|
|
327
|
+
return ` The closest line is line ${best + 1}: ${JSON.stringify(lines[best].trim().slice(0, 120))}`;
|
|
328
|
+
}
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
331
|
+
|
|
182
332
|
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
183
333
|
function checkStale(p, normContent, normOld, ctx) {
|
|
184
334
|
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
@@ -205,15 +355,25 @@ function checkStale(p, normContent, normOld, ctx) {
|
|
|
205
355
|
}
|
|
206
356
|
|
|
207
357
|
// (2) Whole file unchanged since Read, but the edit target was never Read
|
|
208
|
-
// (no region covers it).
|
|
358
|
+
// (no region covers it). Name the exact lines so the model reads the right
|
|
359
|
+
// window instead of guessing.
|
|
209
360
|
if (curFileHash === entry.fileHash) {
|
|
210
|
-
const
|
|
211
|
-
|
|
361
|
+
const span = String(normOld).split('\n').length;
|
|
362
|
+
const at = editLine > 0 ? editLine : 1;
|
|
363
|
+
return `Edit rejected: you have not read the lines you are editing in ${p}. The target is around line ${at} (${span} line${span === 1 ? '' : 's'}). Read it first — ${args_readHint(p, at, span)} — then Edit.`;
|
|
212
364
|
}
|
|
213
365
|
|
|
214
366
|
// (3) The file changed on disk since it was Read and the edit target is not in
|
|
215
367
|
// an intact Read region. Reject so a stale snippet cannot corrupt the file.
|
|
216
|
-
|
|
368
|
+
const at3 = editLine > 0 ? ` (target line ${editLine})` : '';
|
|
369
|
+
return `Edit rejected: ${p} changed since it was Read${at3}, and the edited region is not in an intact Read snapshot. Re-run ${args_readHint(p, editLine > 0 ? editLine : 1, String(normOld).split('\n').length)} to get the current content, then Edit.`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// A ready-to-use Read call for the hint. Keeps the model from guessing line ranges.
|
|
373
|
+
function args_readHint(p, at, span) {
|
|
374
|
+
const from = Math.max(1, at - 3);
|
|
375
|
+
const n = Math.max(span + 6, 10);
|
|
376
|
+
return `Read { path: ${JSON.stringify(p)}, line_offset: ${from}, n_lines: ${n} }`;
|
|
217
377
|
}
|
|
218
378
|
// ---- helpers for the line-range path ---------------------------------------
|
|
219
379
|
|
|
@@ -239,9 +399,9 @@ function checkStaleRange(p, curLines, start, end, ctx) {
|
|
|
239
399
|
// No "(read so far: …)" listing: the range is what the model must act on, and
|
|
240
400
|
// echoing the regions it HAS read only invited it to reason about them instead
|
|
241
401
|
// of re-reading the target lines.
|
|
242
|
-
return `Edit rejected: lines ${start}-${end} were not read in ${p}. Read
|
|
402
|
+
return `Edit rejected: lines ${start}-${end} were not read in ${p}. Read them first — ${args_readHint(p, start, end - start + 1)} — then Edit.`;
|
|
243
403
|
}
|
|
244
|
-
return `Edit rejected: ${p} changed since it was Read (lines ${start}-${end} are not in an intact Read snapshot). Re-run
|
|
404
|
+
return `Edit rejected: ${p} changed since it was Read (lines ${start}-${end} are not in an intact Read snapshot). Re-run ${args_readHint(p, start, end - start + 1)} to get the current content, then Edit.`;
|
|
245
405
|
}
|
|
246
406
|
|
|
247
407
|
// Refresh the region cache after a successful edit: drop regions that no longer
|
package/src/tools/file-lines.js
CHANGED
|
@@ -16,6 +16,11 @@ export const spec = {
|
|
|
16
16
|
required: ['path'],
|
|
17
17
|
},
|
|
18
18
|
async execute(args, ctx) {
|
|
19
|
+
// See read.js: a missing path reaches resolvePath(undefined), which resolves to
|
|
20
|
+
// the workspace root rather than failing.
|
|
21
|
+
if (typeof args.path !== 'string' || !args.path.trim()) {
|
|
22
|
+
return 'Error: `path` is required and must be a non-empty string.';
|
|
23
|
+
}
|
|
19
24
|
let p;
|
|
20
25
|
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
21
26
|
|
package/src/tools/index.js
CHANGED
|
@@ -17,8 +17,10 @@ import * as askUserQuestionMod from './ask-user-question.js';
|
|
|
17
17
|
import { spec as agentSpec } from './agent.js';
|
|
18
18
|
import { spec as agentSwarmSpec } from './agent-swarm.js';
|
|
19
19
|
import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
|
|
20
|
+
import * as memoryMod from './memory.js';
|
|
20
21
|
import { pluginTools } from '../plugin.js';
|
|
21
22
|
|
|
23
|
+
|
|
22
24
|
// Built-in tools.
|
|
23
25
|
const builtinTools = [
|
|
24
26
|
readMod.spec,
|
|
@@ -40,8 +42,10 @@ const builtinTools = [
|
|
|
40
42
|
TaskOutputSpec,
|
|
41
43
|
TaskStopSpec,
|
|
42
44
|
TaskWaitSpec,
|
|
45
|
+
memoryMod.spec,
|
|
43
46
|
];
|
|
44
47
|
|
|
48
|
+
|
|
45
49
|
// Combined list: built-ins + plugin-registered tools. Plugin tools appear after
|
|
46
50
|
// built-ins so their names are visible, but built-in names take precedence in
|
|
47
51
|
// the byName lookup (first one wins).
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Memory tool — the agent records a durable note for FUTURE sessions.
|
|
2
|
+
//
|
|
3
|
+
// Distinct from /personal: that file is the USER's standing preferences and the
|
|
4
|
+
// agent must never write it. This tool writes MEMORY.md, which is the agent's own
|
|
5
|
+
// scratchpad, injected into the system prompt on later turns (see config.js's
|
|
6
|
+
// readMemory). The point is to stop rediscovering the same facts every session —
|
|
7
|
+
// build quirks, a command that works here, a decision the user already made.
|
|
8
|
+
|
|
9
|
+
import { appendMemory, readMemoryRaw, memoryFile } from '../config.js';
|
|
10
|
+
|
|
11
|
+
export const spec = {
|
|
12
|
+
name: 'Memory',
|
|
13
|
+
description: `Save a durable note for FUTURE sessions, or list what is already saved.
|
|
14
|
+
|
|
15
|
+
Use it when you learn something that will still be true next week and that you would
|
|
16
|
+
otherwise have to rediscover: a build/test command that works in this repo, a layout
|
|
17
|
+
quirk, a convention the user asked for, a decision they already made, a gotcha that
|
|
18
|
+
cost you a debugging cycle.
|
|
19
|
+
|
|
20
|
+
Do NOT use it for:
|
|
21
|
+
- things already in AGENTS.md or the code (read them instead — they are the source);
|
|
22
|
+
- facts about the CURRENT task or its progress (use TodoList; this file outlives the task);
|
|
23
|
+
- anything the user asked you to keep quiet, secrets, tokens, or credentials;
|
|
24
|
+
- one-off details you will never need again.
|
|
25
|
+
|
|
26
|
+
Write ONE short note per call, in the user's own language. Prefer several small notes
|
|
27
|
+
over one long entry: a note that is wrong or stale later can be edited out of a file
|
|
28
|
+
you can read back.
|
|
29
|
+
|
|
30
|
+
Scope:
|
|
31
|
+
- project (default) — stored in this workspace, follows the repository;
|
|
32
|
+
- global — stored in your home directory, applies to every workspace.`,
|
|
33
|
+
parameters: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
action: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: ['add', 'list'],
|
|
39
|
+
description: 'add a note, or list what is stored. Defaults to add.',
|
|
40
|
+
},
|
|
41
|
+
note: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'The note to save (one or two sentences). Required when action is add.',
|
|
44
|
+
},
|
|
45
|
+
scope: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
enum: ['project', 'global'],
|
|
48
|
+
description: 'Which memory to write to. Defaults to project.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: [],
|
|
52
|
+
},
|
|
53
|
+
async execute(args, ctx) {
|
|
54
|
+
// A malformed argument must not throw: an uncaught error here aborts the turn.
|
|
55
|
+
const action = String(args.action || 'add').toLowerCase();
|
|
56
|
+
const scope = String(args.scope || 'project').toLowerCase() === 'global' ? 'global' : 'project';
|
|
57
|
+
const workspace = (ctx && (ctx.workspace || ctx.cwd)) || process.cwd();
|
|
58
|
+
|
|
59
|
+
if (action === 'list') {
|
|
60
|
+
const project = readMemoryRaw('project', workspace).trim();
|
|
61
|
+
const global = readMemoryRaw('global', workspace).trim();
|
|
62
|
+
const out = [];
|
|
63
|
+
out.push(`project (${memoryFile('project', workspace)}):`);
|
|
64
|
+
out.push(project || ' (empty)');
|
|
65
|
+
out.push('');
|
|
66
|
+
out.push(`global (${memoryFile('global', workspace)}):`);
|
|
67
|
+
out.push(global || ' (empty)');
|
|
68
|
+
return out.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (action !== 'add') {
|
|
72
|
+
return `Error: unknown action ${JSON.stringify(action)}; use "add" or "list".`;
|
|
73
|
+
}
|
|
74
|
+
const note = String(args.note == null ? '' : args.note).trim();
|
|
75
|
+
if (!note) return 'Error: `note` is required when action is "add".';
|
|
76
|
+
// Guard against the file quietly becoming a transcript. The tool is for durable
|
|
77
|
+
// facts, and a pasted log is neither durable nor a fact.
|
|
78
|
+
if (note.length > 600) {
|
|
79
|
+
return `Error: the note is ${note.length} characters; keep it under 600 (save a summary, not a dump).`;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const file = appendMemory(scope, workspace, note);
|
|
83
|
+
return `Saved to ${scope} memory (${file}).`;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return `Error writing memory: ${e.message}`;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -1,68 +1,85 @@
|
|
|
1
|
-
// ReadMediaFile tool —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
1
|
+
// ReadMediaFile tool — hands a media file to the model as a REAL attachment.
|
|
2
|
+
//
|
|
3
|
+
// An image does not come back as a paragraph describing itself: it returns
|
|
4
|
+
// `{ text, media: [{ type: 'image', mimeType, data }] }`, which the agent stores
|
|
5
|
+
// as the tool result and llm.js converts into the shape each protocol wants
|
|
6
|
+
// (`image_url` for OpenAI, an `image` block for Anthropic). That is what lets the
|
|
7
|
+
// model actually look at a screenshot instead of reading its dimensions.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { resolvePath } from './utils.js';
|
|
12
|
+
|
|
13
|
+
// Bytes, not a bit count. Raised well past the old 20MB/64KB limits: the point of
|
|
14
|
+
// this tool is to LOOK at things, and a modern screenshot or photo routinely
|
|
15
|
+
// exceeds them. Override with HNCODE_MEDIA_MAX_BYTES when a provider has its own
|
|
16
|
+
// ceiling (most cap a single image at 5MB after base64).
|
|
17
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
18
|
+
function maxBytes() {
|
|
19
|
+
const v = Number(process.env.HNCODE_MEDIA_MAX_BYTES);
|
|
20
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_MAX_BYTES;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const IMAGE_MIME = {
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.jpg': 'image/jpeg',
|
|
26
|
+
'.jpeg': 'image/jpeg',
|
|
27
|
+
'.gif': 'image/gif',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.bmp': 'image/bmp',
|
|
30
|
+
'.svg': 'image/svg+xml',
|
|
31
|
+
'.avif': 'image/avif',
|
|
32
|
+
'.ico': 'image/x-icon',
|
|
33
|
+
};
|
|
34
|
+
const VIDEO_EXT = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
|
|
35
|
+
const AUDIO_EXT = ['.mp3', '.wav', '.ogg', '.flac', '.m4a'];
|
|
36
|
+
|
|
37
|
+
export const spec = {
|
|
38
|
+
name: 'ReadMediaFile',
|
|
39
|
+
description: 'Read a media file and attach it to the conversation. Images are sent to the model as an actual image; audio and video return metadata.',
|
|
40
|
+
parameters: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
path: { type: 'string', description: 'Path to the media file.' },
|
|
44
|
+
maxBytes: {
|
|
45
|
+
type: 'number',
|
|
46
|
+
description: 'Reject the file above this many bytes instead of the default limit.',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ['path'],
|
|
50
|
+
},
|
|
51
|
+
async execute(args, ctx) {
|
|
52
|
+
let p;
|
|
53
|
+
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(p)) return `Error: file not found: ${args.path}`;
|
|
56
|
+
const stat = fs.statSync(p);
|
|
57
|
+
if (!stat.isFile()) return `Error: not a file: ${args.path}`;
|
|
58
|
+
|
|
59
|
+
const ext = path.extname(p).toLowerCase();
|
|
60
|
+
const abs = p.replace(/\\/g, '/');
|
|
61
|
+
const sizeKB = Math.max(1, Math.round(stat.size / 1024));
|
|
62
|
+
|
|
63
|
+
if (IMAGE_MIME[ext]) {
|
|
64
|
+
const cap = Number.isFinite(args.maxBytes) && args.maxBytes > 0 ? args.maxBytes : maxBytes();
|
|
65
|
+
if (stat.size > cap) {
|
|
66
|
+
return `Error: image too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${(cap / 1024 / 1024).toFixed(0)}MB): ${args.path}\n`
|
|
67
|
+
+ 'Resize it, or raise the limit with HNCODE_MEDIA_MAX_BYTES.';
|
|
68
|
+
}
|
|
69
|
+
const mimeType = IMAGE_MIME[ext];
|
|
70
|
+
const data = fs.readFileSync(p).toString('base64');
|
|
71
|
+
const approxKB = Math.round(data.length / 1024);
|
|
72
|
+
return {
|
|
73
|
+
text: `[image] ${args.path} (${sizeKB}KB, ${mimeType})`,
|
|
74
|
+
media: [{ type: 'image', mimeType, data }],
|
|
75
|
+
// Kept for the TUI/web transcript, which renders a one-line receipt
|
|
76
|
+
// rather than the bytes.
|
|
77
|
+
_receipt: `Read image ${path.basename(abs)} (${sizeKB}KB, ~${approxKB}KB base64)`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fileType = VIDEO_EXT.includes(ext) ? 'video' : AUDIO_EXT.includes(ext) ? 'audio' : 'unknown';
|
|
82
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
83
|
+
return `[Media: ${fileType.toUpperCase()}] ${args.path} (${sizeMB}MB) - cannot be attached as an image.`;
|
|
84
|
+
},
|
|
85
|
+
};
|