@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.
Files changed (146) hide show
  1. package/package.json +4 -2
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. 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 { resolvePath, ensureDir } from './utils.js';
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 + new_content. Replaces lines [start_line, end_line] with new_content. 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_content or new_string is an error, not an empty replacement.',
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 for old_string.' },
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 new_content). Use this line-range mode first.' },
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+new_content) OR exact-substring
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', 'new_content'] },
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 { content = fs.readFileSync(p, 'utf8'); } catch (e) { return `Error reading ${args.path}: ${e.message}`; }
41
- const { start_line, end_line, new_content } = args;
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 || args.new_content != null;
46
- const hasSubKeys = args.old_string != null || args.new_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/new_content) OR (old_string/new_string), never both.`;
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
- // Neither start/end alone is a valid range, and new_content is required —
59
- // a missing one would put the literal string "undefined" in the file.
60
- if (new_content == null) return `Error: new_content is required for line-range mode.`;
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(new_content).split('\n');
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 `old_content` -> `new_content`.
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(new_content), startLine: start };
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
- const count = normContent.split(normOld).length - 1;
117
- if (count === 0) return `Error: old_string not found in ${args.path}.`;
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, normOld, ctx);
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(normOld, normNew)
131
- : normContent.replace(normOld, normNew);
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(normNew).split('\n')[0];
233
+ const anchor = String(matchNew).split('\n')[0];
151
234
  let at = newLines.indexOf(anchor);
152
- if (at < 0) at = newLines.indexOf(String(normOld).split('\n')[0]);
235
+ if (at < 0) at = newLines.indexOf(String(matchOld).split('\n')[0]);
153
236
  if (at >= 0) {
154
- const newSpan = String(normNew).split('\n').length;
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). Tell the AI to read the target lines first.
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 ranges = entry.regions.map((r) => `${r.start}-${r.end}`).join(', ') || 'none';
211
- return `Edit rejected: you have not read the lines you are editing in ${p}. Read the target lines first (Read with line_offset / n_lines), then Edit.`;
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
- return `Edit rejected: ${p} changed since it was Read (the edited region is not in an intact Read snapshot). Re-run Read to get the current content, then Edit.`;
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 the target lines first (Read with line_offset / n_lines), then Edit.`;
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 Read to get the current content, then Edit.`;
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
@@ -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
 
@@ -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 — reads media files and returns base64 for images,
2
- // or metadata for other formats (video, audio).
3
- // Allows multimodal models to view images and identify media types.
4
-
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { resolvePath } from './utils.js';
8
-
9
- const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB max for images
10
- const MAX_PREVIEW_SIZE = 100 * 1024; // 100KB text preview for non-images
11
-
12
- const SUPPORTED_IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
13
- const VIDEO_EXT = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
14
- const AUDIO_EXT = ['.mp3', '.wav', '.ogg', '.flac', '.m4a'];
15
-
16
- export const spec = {
17
- name: 'ReadMediaFile',
18
- description: 'Read a media file. Images return base64; other formats return metadata.',
19
- parameters: {
20
- type: 'object',
21
- properties: {
22
- path: { type: 'string', description: 'Path to the media file.' },
23
- },
24
- required: ['path'],
25
- },
26
- async execute(args, ctx) {
27
- let p;
28
- try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
29
-
30
- if (!fs.existsSync(p)) return `Error: file not found: ${args.path}`;
31
-
32
- const stat = fs.statSync(p);
33
- const ext = path.extname(p).toLowerCase();
34
-
35
- if (SUPPORTED_IMAGE_EXT.includes(ext)) {
36
- if (stat.size > MAX_IMAGE_SIZE) {
37
- return `Error: image too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max 20MB): ${args.path}`;
38
- }
39
- // A tool result is plain TEXT that goes back into the model's context on
40
- // the next request. A 20MB image becomes ~27MB of base64, which blows the
41
- // context window and (for OpenAI/Anthropic chat completions) is not a
42
- // recognised image payload anyway — the model only sees a wall of text.
43
- // Return a REFERENCE the caller can act on instead of the bytes.
44
- const mimeType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
45
- : ext === '.png' ? 'image/png'
46
- : ext === '.gif' ? 'image/gif'
47
- : ext === '.webp' ? 'image/webp'
48
- : ext === '.bmp' ? 'image/bmp'
49
- : 'image/unknown';
50
- const sizeKB = Math.max(1, Math.round(stat.size / 1024));
51
- const abs = p.replace(/\\/g, '/');
52
- // Keep a small inline preview ONLY for tiny files (< 64KB), where the
53
- // base64 is at most ~87KB and genuinely usable by a multimodal client.
54
- if (stat.size <= 64 * 1024) {
55
- const base64 = fs.readFileSync(p).toString('base64');
56
- return `data:${mimeType};base64,${base64}`;
57
- }
58
- return `[Image: ${mimeType}] ${args.path} (${sizeKB}KB, ${stat.width || '?'}x${stat.height || '?'} unknown)\n`
59
- + `Path: ${abs}\n`
60
- + `This image is too large to inline as base64 (would be ~${Math.round(stat.size * 4 / 3 / 1024)}KB of text). `
61
- + `To view it, use a client that renders image paths, or resize it first.`;
62
- }
63
-
64
- const fileType = VIDEO_EXT.includes(ext) ? 'video' : AUDIO_EXT.includes(ext) ? 'audio' : 'unknown';
65
- const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
66
- return `[Media: ${fileType.toUpperCase()}] ${args.path} (${sizeMB}MB) - cannot preview in text mode.`;
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
+ };