@hncode/hncode 0.6.0 → 0.6.2
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 +37 -37
- package/src/clipboard.js +221 -33
- package/src/tools/goal.js +93 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +421 -130
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"hncode": "bin/hncode"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"bin/",
|
|
12
|
-
"src/",
|
|
13
|
-
"LICENSE",
|
|
14
|
-
"README.md",
|
|
15
|
-
"README_CN.md",
|
|
16
|
-
"package.json"
|
|
17
|
-
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
-
"install": "node bin/install.cjs",
|
|
21
|
-
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
-
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
-
"test": "node --test \"test/**/*.test.mjs\""
|
|
24
|
-
},
|
|
25
|
-
"engines": {
|
|
26
|
-
"node": ">=20"
|
|
27
|
-
},
|
|
28
|
-
"license": "PolyForm-Noncommercial-1.0.0",
|
|
29
|
-
"author": "NiceHello666",
|
|
30
|
-
"keywords": [
|
|
31
|
-
"cli",
|
|
32
|
-
"agent",
|
|
33
|
-
"coding",
|
|
34
|
-
"ai",
|
|
35
|
-
"tui"
|
|
36
|
-
]
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@hncode/hncode",
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hncode": "bin/hncode"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"README_CN.md",
|
|
16
|
+
"package.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
+
"install": "node bin/install.cjs",
|
|
21
|
+
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
+
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
29
|
+
"author": "NiceHello666",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"cli",
|
|
32
|
+
"agent",
|
|
33
|
+
"coding",
|
|
34
|
+
"ai",
|
|
35
|
+
"tui"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/clipboard.js
CHANGED
|
@@ -62,33 +62,51 @@ function win() { return process.platform === 'win32'; }
|
|
|
62
62
|
/**
|
|
63
63
|
* Put text on the clipboard.
|
|
64
64
|
*
|
|
65
|
-
* WINDOWS: `
|
|
65
|
+
* WINDOWS: `clip.exe` on STDIN, with the PowerShell path as the fallback.
|
|
66
66
|
*
|
|
67
|
-
* `clip.exe`
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* on every one:
|
|
67
|
+
* An earlier version chose `Set-Clipboard` because `clip.exe` prefixed every copy with a zero-width U+FEFF.
|
|
68
|
+
* That was a BOM problem, not a `clip.exe` problem: it reads stdin as a byte stream, so a leading `FF FE` is
|
|
69
|
+
* two literal characters. Sent as plain UTF-16LE with no mark, it round-trips exactly — verified on ASCII,
|
|
70
|
+
* Chinese, emoji, embedded newlines and 400000 characters.
|
|
72
71
|
*
|
|
73
|
-
*
|
|
74
|
-
* hello U+FEFF hello hello
|
|
75
|
-
* 中文测试 U+FEFF 中文测试 中文测试
|
|
76
|
-
* line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
|
|
77
|
-
* emoji 🎉 ok U+FEFF emoji 🎉 ok emoji 🎉 ok
|
|
72
|
+
* Two measured reasons to prefer it:
|
|
78
73
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
74
|
+
* characters clip.exe pwsh one-shot
|
|
75
|
+
* 100 77ms 788ms
|
|
76
|
+
* 5000 56ms 748ms
|
|
77
|
+
* 20000 61ms ENAMETOOLONG <- the command line is capped near 32KB
|
|
78
|
+
* 400000 58ms ENAMETOOLONG
|
|
81
79
|
*
|
|
82
|
-
* `clip.exe`
|
|
83
|
-
*
|
|
80
|
+
* `clip.exe` takes the text on STDIN, so its cost does not depend on the length and it has no ceiling; the
|
|
81
|
+
* PowerShell route puts the text on its COMMAND LINE and fails outright past ~20k characters. The one
|
|
82
|
+
* difference that remains: `clip.exe` normalises `\n` to `\r\n`, which is what the Windows clipboard expects
|
|
83
|
+
* anyway.
|
|
84
|
+
*
|
|
85
|
+
* `setClipboardText` stays for a machine with no `clip.exe`.
|
|
84
86
|
*/
|
|
85
87
|
export function copyText(text) {
|
|
86
88
|
const s = String(text == null ? '' : text);
|
|
87
89
|
if (!s) return false;
|
|
88
90
|
if (win()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
// `clip.exe` FIRST, fed UTF-8 through a 65001 console.
|
|
92
|
+
//
|
|
93
|
+
// Measured 150-193ms at every size from 100 to 400000 characters, against 748-788ms for a PowerShell
|
|
94
|
+
// one-shot — and the PowerShell route puts the text on its COMMAND LINE, which Windows caps near 32KB, so
|
|
95
|
+
// a 20k-character copy failed outright with ENAMETOOLONG.
|
|
96
|
+
//
|
|
97
|
+
// THE ENCODING IS NOT UTF-16. `clip.exe` decodes stdin with the console code page, which is 936 (GBK) on a
|
|
98
|
+
// Chinese Windows: UTF-16LE bytes came back byte-swapped (`你好` U+4F60 U+597D arrived as U+604F U+597D)
|
|
99
|
+
// and UTF-8 bytes without the matching code page came back as mojibake. `chcp 65001` on the child and UTF-8
|
|
100
|
+
// in the pipe is what round-trips — verified on ASCII, Chinese, emoji and 400k characters.
|
|
101
|
+
//
|
|
102
|
+
// Synchronous, because the caller announces "Copied" the moment this returns.
|
|
103
|
+
const r = cp.spawnSync('cmd', ['/c', 'chcp 65001 >nul && clip'], {
|
|
104
|
+
input: Buffer.from(s, 'utf8'),
|
|
105
|
+
windowsHide: true,
|
|
106
|
+
timeout: 5000,
|
|
107
|
+
});
|
|
108
|
+
if (r && r.status === 0) return true;
|
|
109
|
+
return setClipboardText(s);
|
|
92
110
|
}
|
|
93
111
|
const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
|
|
94
112
|
cp.execSync(cmd, { input: Buffer.from(s, 'utf8'), timeout: 5000 });
|
|
@@ -113,6 +131,41 @@ function setClipboardText(s) {
|
|
|
113
131
|
return false;
|
|
114
132
|
}
|
|
115
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Put text on the clipboard WITHOUT blocking the event loop.
|
|
136
|
+
*
|
|
137
|
+
* `copyText` is synchronous because most callers want to know the outcome before they say "Copied". A copy is
|
|
138
|
+
* 150-190ms of child process on Windows, which is long enough to be felt as a stutter when it runs between two
|
|
139
|
+
* paints. The TUI uses this where a reply is not needed.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} text
|
|
142
|
+
* @returns {Promise<boolean>}
|
|
143
|
+
*/
|
|
144
|
+
export function copyTextAsync(text) {
|
|
145
|
+
const s = String(text == null ? '' : text);
|
|
146
|
+
if (!s) return Promise.resolve(false);
|
|
147
|
+
if (!win()) {
|
|
148
|
+
try { copyText(s); return Promise.resolve(true); } catch { return Promise.resolve(false); }
|
|
149
|
+
}
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
try {
|
|
152
|
+
// Same command and encoding as `copyText`: the code page has to be set on the child, and the payload is
|
|
153
|
+
// UTF-8. See the note there for why UTF-16 does not work.
|
|
154
|
+
const child = cp.spawn('cmd', ['/c', 'chcp 65001 >nul && clip'], {
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
157
|
+
});
|
|
158
|
+
child.on('error', () => resolve(false));
|
|
159
|
+
child.on('close', (code) => resolve(code === 0));
|
|
160
|
+
child.stdin.on('error', () => { /* a broken pipe is reported by `close` */ });
|
|
161
|
+
child.stdin.end(Buffer.from(s, 'utf8'));
|
|
162
|
+
} catch {
|
|
163
|
+
// No clip.exe: fall back to the blocking route rather than losing the copy.
|
|
164
|
+
try { resolve(setClipboardText(s)); } catch { resolve(false); }
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
116
169
|
// Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
|
|
117
170
|
// `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
|
|
118
171
|
// path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
|
|
@@ -193,7 +246,13 @@ function startHelper(exe) {
|
|
|
193
246
|
// unavailable" for a clipboard that had content. The queued resolver here is what
|
|
194
247
|
// marks the shell as ready, so one sentinel still has exactly one owner.
|
|
195
248
|
helperQueue.push(() => { helperReady = true; });
|
|
196
|
-
|
|
249
|
+
// The sequence probe's P/Invoke declaration is compiled here too: the first `Add-Type` on a cold shell
|
|
250
|
+
// measured ~900ms, which is exactly the cost this warm-up exists to move off the paste path.
|
|
251
|
+
helper.stdin.write(
|
|
252
|
+
"try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n"
|
|
253
|
+
+ "try { if(-not ('HnClip' -as [type])){Add-Type -Namespace '' -Name 'HnClip' -MemberDefinition '[DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'} } catch {}\n"
|
|
254
|
+
+ "'" + SENT + "'\n",
|
|
255
|
+
);
|
|
197
256
|
}
|
|
198
257
|
|
|
199
258
|
/** Tear down the helper and release every waiter. A read must never be left hanging on
|
|
@@ -249,7 +308,10 @@ function helperRun(script, timeoutMs) {
|
|
|
249
308
|
function readOnce() {
|
|
250
309
|
if (win()) {
|
|
251
310
|
const b64 = runPowershell(
|
|
252
|
-
|
|
311
|
+
// `Clipboard::GetText()` instead of `Get-Clipboard -Raw`: measured on a 20k-character text, the cmdlet
|
|
312
|
+
// took 169ms and the .NET call 13ms. `Get-Clipboard` goes through PowerShell's COM wrapper, which
|
|
313
|
+
// copies the string several times before it reaches the pipeline.
|
|
314
|
+
'-NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([System.Windows.Forms.Clipboard]::GetText() + \'\'))"',
|
|
253
315
|
{ encoding: 'ascii', windowsHide: true, timeout: ONESHOT_MS },
|
|
254
316
|
);
|
|
255
317
|
if (b64 == null) throw new Error('no powershell');
|
|
@@ -269,7 +331,7 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
|
|
|
269
331
|
// warming is answered by nothing, and the cold path is the expensive one.
|
|
270
332
|
if (helper && await waitHelperReady(HELPER_READY_MS)) {
|
|
271
333
|
helperBusy = true;
|
|
272
|
-
const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((
|
|
334
|
+
const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([System.Windows.Forms.Clipboard]::GetText() + ""))', timeoutMs);
|
|
273
335
|
if (out != null) {
|
|
274
336
|
const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
|
|
275
337
|
try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
|
|
@@ -301,7 +363,38 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
|
|
|
301
363
|
// PowerShell expression (silently returned nothing), which made FILE pastes fall
|
|
302
364
|
// back to the slow cold spawn. Emitting one F:: line per file is simpler and
|
|
303
365
|
// also supports multi-file copies.
|
|
304
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Just the clipboard's TEXT, or '' — no file list, no image.
|
|
368
|
+
*
|
|
369
|
+
* Asked before paying for a PNG: a browser's image copy carries the pixels AND the address, and only the text
|
|
370
|
+
* formats can hold that address. Cheap on the warm helper, which is already running by the time anyone pastes.
|
|
371
|
+
*
|
|
372
|
+
* @returns {Promise<string>}
|
|
373
|
+
*/
|
|
374
|
+
export async function readClipboardTextOnly() {
|
|
375
|
+
if (!win()) { try { return readOnce() || ''; } catch { return ''; } }
|
|
376
|
+
warmClipboard();
|
|
377
|
+
if (!helper) {
|
|
378
|
+
try { return readOnce() || ''; } catch { return ''; }
|
|
379
|
+
}
|
|
380
|
+
if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) {
|
|
381
|
+
try { return readOnce() || ''; } catch { return ''; }
|
|
382
|
+
}
|
|
383
|
+
for (let i = 0; i < 20 && helperBusy; i++) await new Promise((r) => setTimeout(r, 25));
|
|
384
|
+
if (helperBusy || !helper) {
|
|
385
|
+
try { return readOnce() || ''; } catch { return ''; }
|
|
386
|
+
}
|
|
387
|
+
helperBusy = true;
|
|
388
|
+
let out;
|
|
389
|
+
try {
|
|
390
|
+
out = await helperRun(CLIP_PROBE_TEXT, HELPER_CMD_MS);
|
|
391
|
+
} finally {
|
|
392
|
+
helperBusy = false;
|
|
393
|
+
}
|
|
394
|
+
const line = String(out || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('T::'));
|
|
395
|
+
if (!line) return '';
|
|
396
|
+
try { return Buffer.from(line.slice(3), 'base64').toString('utf8'); } catch { return ''; }
|
|
397
|
+
}
|
|
305
398
|
// The image branch uses the .NET WinForms clipboard, NOT `Get-Clipboard -Image`:
|
|
306
399
|
// that parameter only exists on some builds (and is absent even from the pwsh 7
|
|
307
400
|
// in use here), so it silently failed everywhere and image paste never worked.
|
|
@@ -321,15 +414,24 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
|
|
|
321
414
|
*/
|
|
322
415
|
const CLIP_PROBE_TEXT = [
|
|
323
416
|
"$ErrorActionPreference='SilentlyContinue'",
|
|
324
|
-
|
|
417
|
+
// .NET, not the cmdlet: 13ms against 169ms on the same 20k-character text.
|
|
418
|
+
"$t=[System.Windows.Forms.Clipboard]::GetText()",
|
|
325
419
|
"if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t))}",
|
|
326
420
|
].join('; ');
|
|
327
421
|
|
|
328
422
|
const CLIP_PROBE = [
|
|
329
423
|
"$ErrorActionPreference='SilentlyContinue'",
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
424
|
+
// FILES COME FROM THE .NET CLIPBOARD, not `Get-Clipboard -Format FileDropList`.
|
|
425
|
+
//
|
|
426
|
+
// That parameter does not exist in PowerShell 7 — `Get-Clipboard: A parameter cannot be found that matches
|
|
427
|
+
// parameter name 'Format'` — and the `SilentlyContinue` above turns the error into an empty result, so a
|
|
428
|
+
// copied file read as "clipboard unavailable" while copied text worked. `Clipboard::GetFileDropList()` is the
|
|
429
|
+
// same call on both versions; verified on the 5.1 build in System32 and on pwsh 7 here.
|
|
430
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
431
|
+
"$f=[System.Windows.Forms.Clipboard]::GetFileDropList()",
|
|
432
|
+
"if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_};return}",
|
|
433
|
+
// .NET, not the cmdlet: 13ms against 169ms on the same 20k-character text.
|
|
434
|
+
"$t=[System.Windows.Forms.Clipboard]::GetText()",
|
|
333
435
|
"if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
|
|
334
436
|
// The assemblies are loaded once by warmClipboard(); no Add-Type here, so the
|
|
335
437
|
// probe stays fast even the first time an image is on the clipboard.
|
|
@@ -337,6 +439,22 @@ const CLIP_PROBE = [
|
|
|
337
439
|
"if($null -ne $i){$p=Join-Path $env:TEMP ('hncode-paste-'+[guid]::NewGuid().ToString('N')+'.png');$i.Save($p,[System.Drawing.Imaging.ImageFormat]::Png);'I::'+$p}",
|
|
338
440
|
].join('; ');
|
|
339
441
|
|
|
442
|
+
/**
|
|
443
|
+
* The clipboard's sequence number, one integer.
|
|
444
|
+
*
|
|
445
|
+
* Windows bumps it on every clipboard write, so comparing two readings answers "did the content change"
|
|
446
|
+
* without reading the content. `Get-Clipboard` has no equivalent, so this P/Invokes `user32` — once, because
|
|
447
|
+
* the type stays compiled for the life of the warm shell.
|
|
448
|
+
*
|
|
449
|
+
* A real read costs time proportional to the payload (measured: 14ms at 1k characters, 48ms at 400k); this
|
|
450
|
+
* does not, which is what makes a repeat paste of a long text instant.
|
|
451
|
+
*/
|
|
452
|
+
const CLIP_SEQ_PROBE = [
|
|
453
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
454
|
+
"if(-not ('HnClip' -as [type])){Add-Type -Namespace '' -Name 'HnClip' -MemberDefinition '[DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'}",
|
|
455
|
+
"'S::'+[HnClip]::GetClipboardSequenceNumber()",
|
|
456
|
+
].join('; ');
|
|
457
|
+
|
|
340
458
|
// Parse the probe output into { text, via }. Handles one-or-more F:: lines.
|
|
341
459
|
function parseClipResult(out) {
|
|
342
460
|
const lines = String(out == null ? '' : out).replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
@@ -353,6 +471,38 @@ function parseClipResult(out) {
|
|
|
353
471
|
return null;
|
|
354
472
|
}
|
|
355
473
|
|
|
474
|
+
/**
|
|
475
|
+
* A URL hidden in the TEXT of a copied image, or null.
|
|
476
|
+
*
|
|
477
|
+
* Copying an image out of a browser puts several formats on the clipboard at once: the pixels, an HTML
|
|
478
|
+
* fragment (`<img src="…">`, sometimes a `data:` URL) and, for a plain image, the image's own address. The
|
|
479
|
+
* pixel route costs a PNG written to disk for something the model can fetch directly — so the text formats
|
|
480
|
+
* are checked FIRST and only a clipboard with no address at all becomes a file.
|
|
481
|
+
*
|
|
482
|
+
* Only http(s) and `data:` are accepted. A `file:` URL is the same thing as a copied file, which the file
|
|
483
|
+
* drop list already covers.
|
|
484
|
+
*
|
|
485
|
+
* @param {string} text The clipboard's text, possibly an HTML fragment.
|
|
486
|
+
* @returns {string|null}
|
|
487
|
+
*/
|
|
488
|
+
export function imageUrlFromClipboardText(text) {
|
|
489
|
+
const s = String(text || '').trim();
|
|
490
|
+
if (!s) return null;
|
|
491
|
+
// A bare URL: the image's own address was copied.
|
|
492
|
+
const bare = /^(https?:\/\/\S+|data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+)$/i.exec(s);
|
|
493
|
+
if (bare) return bare[1].replace(/\s+/g, '').trim();
|
|
494
|
+
// An HTML fragment: take the src of the first <img>, which is what a browser's image copy produces.
|
|
495
|
+
const img = /<img\b[^>]*\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(s);
|
|
496
|
+
if (img) {
|
|
497
|
+
const url = (img[2] || img[3] || img[4] || '').trim();
|
|
498
|
+
if (/^(https?:\/\/|data:image\/)/i.test(url)) return url.replace(/\s+/g, '');
|
|
499
|
+
}
|
|
500
|
+
// A plain address on its own line inside a longer text selection.
|
|
501
|
+
const line = s.split(/\r?\n/).map((l) => l.trim())
|
|
502
|
+
.find((l) => /^https?:\/\/\S+\.(png|jpe?g|gif|webp|bmp|svg)(\?\S*)?$/i.test(l));
|
|
503
|
+
return line || null;
|
|
504
|
+
}
|
|
505
|
+
|
|
356
506
|
// ---- pasted-image lifecycle ------------------------------------------------
|
|
357
507
|
// A pasted image is written to a temp .png (the model needs a PATH to hand to
|
|
358
508
|
// ReadMediaFile), so something has to remove it again or the temp dir fills up
|
|
@@ -442,14 +592,49 @@ export function readClipboardContent() {
|
|
|
442
592
|
return parseClipResult(out) || { text: '', via: 'none' };
|
|
443
593
|
}
|
|
444
594
|
|
|
595
|
+
// ---- the fast path's memory --------------------------------------------------
|
|
596
|
+
//
|
|
597
|
+
// `GetClipboardSequenceNumber` is one integer, and Windows bumps it on every clipboard write. Asking the
|
|
598
|
+
// helper for it is a single cheap call; when it matches the last read, the previous result is returned without
|
|
599
|
+
// touching the clipboard at all. That is what makes a repeat paste instant regardless of how long the text is —
|
|
600
|
+
// the read cost scaled with the payload, but the sequence check does not.
|
|
601
|
+
let lastSeq = null;
|
|
602
|
+
let lastResult = null;
|
|
603
|
+
/** The sequence number alone, read on the warm helper. Null when it is not available. */
|
|
604
|
+
async function readClipboardSequence() {
|
|
605
|
+
warmClipboard();
|
|
606
|
+
if (!helper) return null;
|
|
607
|
+
if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) return null;
|
|
608
|
+
if (helperBusy) return null; // somebody is mid-command; the caller can just read normally
|
|
609
|
+
helperBusy = true;
|
|
610
|
+
let out;
|
|
611
|
+
try {
|
|
612
|
+
out = await helperRun(CLIP_SEQ_PROBE, HELPER_CMD_MS);
|
|
613
|
+
} finally {
|
|
614
|
+
helperBusy = false;
|
|
615
|
+
}
|
|
616
|
+
const line = String(out || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('S::'));
|
|
617
|
+
if (!line) return null;
|
|
618
|
+
const n = Number(line.slice(3));
|
|
619
|
+
return Number.isFinite(n) ? n : null;
|
|
620
|
+
}
|
|
621
|
+
|
|
445
622
|
// Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
|
|
446
623
|
export async function readClipboardContentAsync() {
|
|
447
624
|
if (!win()) {
|
|
448
625
|
try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
|
|
449
626
|
}
|
|
627
|
+
// A CLIPBOARD THAT HAS NOT CHANGED IS NOT READ AGAIN.
|
|
628
|
+
//
|
|
629
|
+
// The measured cost of a read is dominated by the payload (14ms for 1k characters, 48ms for 400k) and the
|
|
630
|
+
// user pays it on every paste even when the clipboard holds the same thing. The sequence number answers "did
|
|
631
|
+
// it change" in one cheap call, so a repeat paste of the same large text is instant.
|
|
632
|
+
const seq = await readClipboardSequence();
|
|
633
|
+
if (seq !== null && seq === lastSeq && lastResult) return { ...lastResult };
|
|
450
634
|
const viaHelper = await readViaHelper();
|
|
451
|
-
|
|
452
|
-
|
|
635
|
+
const result = viaHelper || readClipboardContent();
|
|
636
|
+
if (seq !== null) { lastSeq = seq; lastResult = result; }
|
|
637
|
+
return result;
|
|
453
638
|
}
|
|
454
639
|
|
|
455
640
|
// The PowerShell logic for the one-shot fallback lives in a temp .ps1 file:
|
|
@@ -461,13 +646,16 @@ function clipScriptPath() {
|
|
|
461
646
|
const p = path.join(os.tmpdir(), 'hncode-clipread.ps1');
|
|
462
647
|
const script = [
|
|
463
648
|
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
'$txt = Get-Clipboard -Raw',
|
|
467
|
-
'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
|
|
468
|
-
// .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
|
|
649
|
+
// .NET clipboard for BOTH files and images. `Get-Clipboard -Format FileDropList` is missing from
|
|
650
|
+
// PowerShell 7, and with SilentContinue that turned a copied file into "nothing on the clipboard".
|
|
469
651
|
'Add-Type -AssemblyName System.Windows.Forms',
|
|
470
652
|
'Add-Type -AssemblyName System.Drawing',
|
|
653
|
+
'$files = [System.Windows.Forms.Clipboard]::GetFileDropList()',
|
|
654
|
+
'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_ }; exit }',
|
|
655
|
+
// .NET, not the cmdlet: the cmdlet copies the string several times before the pipeline sees it.
|
|
656
|
+
'$txt = [System.Windows.Forms.Clipboard]::GetText()',
|
|
657
|
+
'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
|
|
658
|
+
// .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
|
|
471
659
|
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
|
|
472
660
|
'if ($null -ne $img) {',
|
|
473
661
|
' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Goal tool: the agent's own objective across turns.
|
|
3
|
+
*
|
|
4
|
+
* A turn ends when the model stops. Some work does not fit in one turn — a refactor across a dozen files, a
|
|
5
|
+
* migration, a suite that needs several rounds — and without a goal the agent simply stops halfway and waits
|
|
6
|
+
* to be told to continue. The goal makes that explicit: a line the agent wrote for itself, shown in the status
|
|
7
|
+
* panel, and the turn loop restarts the agent while one is set.
|
|
8
|
+
*
|
|
9
|
+
* ONE STORE, NOT TWO. The objective lives in the TUI's `state.objective` — the same field `/goal` writes — and
|
|
10
|
+
* this module only borrows it through a listener. A second copy here would drift the moment the user typed
|
|
11
|
+
* `/goal cancel`, and the loop would restart against an objective nobody could see.
|
|
12
|
+
*
|
|
13
|
+
* THREE ACTIONS:
|
|
14
|
+
*
|
|
15
|
+
* set Start or replace the objective.
|
|
16
|
+
* status Read it back, so a resumed turn can re-read what it was doing.
|
|
17
|
+
* clear Finish. Nothing restarts after this, which is why it has to be deliberate.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Read the goal. Installed by startTUI, backed by `state.objective`. */
|
|
21
|
+
let readGoal = () => '';
|
|
22
|
+
/** Write the goal. Installed by startTUI. */
|
|
23
|
+
let writeGoal = () => {};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{get: () => string, set: (goal: string) => void}} api
|
|
27
|
+
*/
|
|
28
|
+
export function installGoalStore(api) {
|
|
29
|
+
if (api && typeof api.get === 'function') readGoal = api.get;
|
|
30
|
+
if (api && typeof api.set === 'function') writeGoal = api.set;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The current objective, or ''. */
|
|
34
|
+
export function getGoal() {
|
|
35
|
+
try { return String(readGoal() || '').trim(); } catch { return ''; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const spec = {
|
|
39
|
+
name: 'Goal',
|
|
40
|
+
description: [
|
|
41
|
+
'Set, read or clear the objective you are working towards across turns.',
|
|
42
|
+
'',
|
|
43
|
+
'A turn ends when you stop responding. While a goal is set the agent restarts you with it quoted back, so',
|
|
44
|
+
'use it for work that genuinely needs several turns — not for something you can finish now.',
|
|
45
|
+
'',
|
|
46
|
+
'- set: start or replace it. Write a finishable outcome ("all callers of X migrated and the suite passes"),',
|
|
47
|
+
' not an activity ("work on X").',
|
|
48
|
+
'- status: read it back.',
|
|
49
|
+
'- clear: you are done. Call this the moment the goal is met; otherwise the loop restarts you again.',
|
|
50
|
+
'',
|
|
51
|
+
'The user can set the same goal with /goal and can pause it. Never clear a goal you have not met.',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
parameters: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
action: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
enum: ['set', 'status', 'clear'],
|
|
59
|
+
description: 'set = start or replace, status = read back, clear = finished.',
|
|
60
|
+
},
|
|
61
|
+
goal: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'The objective. Required for `set`; a finishable outcome, not an activity.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['action'],
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {{action: string, goal?: string}} args
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
execute(args) {
|
|
74
|
+
const action = String((args && args.action) || '').toLowerCase();
|
|
75
|
+
if (action === 'set') {
|
|
76
|
+
const goal = String((args && args.goal) || '').trim();
|
|
77
|
+
if (!goal) return 'Error: `set` needs a goal — the outcome you are working towards.';
|
|
78
|
+
try { writeGoal(goal); } catch { return 'Error: could not store the goal.'; }
|
|
79
|
+
return `Goal set: ${goal}\nThe turn loop restarts you with this until you call Goal with action clear.`;
|
|
80
|
+
}
|
|
81
|
+
if (action === 'status') {
|
|
82
|
+
const goal = getGoal();
|
|
83
|
+
return goal ? `Goal: ${goal}` : 'No goal is set.';
|
|
84
|
+
}
|
|
85
|
+
if (action === 'clear') {
|
|
86
|
+
const goal = getGoal();
|
|
87
|
+
if (!goal) return 'No goal to clear.';
|
|
88
|
+
try { writeGoal(''); } catch { return 'Error: could not clear the goal.'; }
|
|
89
|
+
return `Goal cleared: ${goal}\nNothing will restart this turn now.`;
|
|
90
|
+
}
|
|
91
|
+
return `Error: unknown action "${args && args.action}". Use set, status or clear.`;
|
|
92
|
+
},
|
|
93
|
+
};
|
package/src/tools/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { TEAM_TOOL_SPECS } from './team.js';
|
|
|
22
22
|
import { spec as notebookSpec } from './notebook.js';
|
|
23
23
|
import { spec as diagnosticsSpec } from './diagnostics.js';
|
|
24
24
|
import * as memoryMod from './memory.js';
|
|
25
|
+
import * as goalMod from './goal.js';
|
|
25
26
|
import { pluginTools, pluginToolGeneration } from '../plugin.js';
|
|
26
27
|
|
|
27
28
|
|
|
@@ -55,6 +56,7 @@ const builtinTools = [
|
|
|
55
56
|
notebookSpec,
|
|
56
57
|
diagnosticsSpec,
|
|
57
58
|
memoryMod.spec,
|
|
59
|
+
goalMod.spec,
|
|
58
60
|
...TEAM_TOOL_SPECS,
|
|
59
61
|
];
|
|
60
62
|
|