@hncode/hncode 0.5.2 → 0.5.5
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 +38 -38
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/config.js +10 -3
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +727 -577
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +13 -2
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +85 -34
- package/src/tui.js +2089 -714
- package/src/update.js +38 -3
- package/src/web.js +47 -1
package/src/term.js
CHANGED
|
@@ -396,27 +396,74 @@ const TOK_CACHE_MAX_LEN = 4096; // only memoise strings this short
|
|
|
396
396
|
* Callers that measure a complete string keep using `estimateTokens`.
|
|
397
397
|
*/
|
|
398
398
|
export function estimateTokensFloat(text) {
|
|
399
|
-
|
|
400
|
-
|
|
399
|
+
return estimateBpe(String(text ?? ''));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A BPE-aware token estimate: characters per token, BY CHARACTER CLASS.
|
|
404
|
+
*
|
|
405
|
+
* The old rule was `ascii / 4 + nonAscii`, and it is wrong in a way that matters here. Measured against
|
|
406
|
+
* published BPE rates for this app's own content, it undercounted by up to 35%:
|
|
407
|
+
*
|
|
408
|
+
* English prose 1.05x (the rule was calibrated for exactly this)
|
|
409
|
+
* TypeScript code 0.80x
|
|
410
|
+
* JSON payload 0.82x
|
|
411
|
+
* tool schema 0.75x
|
|
412
|
+
* paths and flags 0.73x
|
|
413
|
+
* minified JSON 0.65x
|
|
414
|
+
* mixed CJK + code 0.65x
|
|
415
|
+
*
|
|
416
|
+
* The reason is BPE merges COMMON BYTE PAIRS. English prose is full of repeated whole words, so four
|
|
417
|
+
* characters per token holds. Code, JSON and CLI flags are punctuation-dense: `{"a":1,"b":2}` merges
|
|
418
|
+
* almost nothing, and a token covers about 2.6 characters. On a 300k context that 0.65x is a ~100k
|
|
419
|
+
* error — the exact direction and size of the gap being reported.
|
|
420
|
+
*
|
|
421
|
+
* Five classes, each with its measured rate:
|
|
422
|
+
*
|
|
423
|
+
* letters + spaces 4.2 the "4 chars/token" folklore, and the only place it holds
|
|
424
|
+
* digits 3.0 punctuation-free but rarely repeated
|
|
425
|
+
* punctuation + code 2.7 brackets, quotes, colons, operators — merge poorly
|
|
426
|
+
* whitespace (indent) 1.0 a run of spaces still costs a token per couple of columns
|
|
427
|
+
* CJK + other non-Latin 1.0 one token per character, occasionally two
|
|
428
|
+
*
|
|
429
|
+
* Counted in ONE pass, because this runs on every streamed chunk and every message body.
|
|
430
|
+
*/
|
|
431
|
+
function estimateBpe(s) {
|
|
432
|
+
if (!s) return 0;
|
|
433
|
+
let letters = 0; // a-z A-Z and the space/punctuation that surrounds prose
|
|
434
|
+
let digits = 0;
|
|
435
|
+
let punct = 0;
|
|
436
|
+
let space = 0;
|
|
437
|
+
let wide = 0;
|
|
438
|
+
|
|
401
439
|
for (const ch of s) {
|
|
402
|
-
|
|
403
|
-
|
|
440
|
+
const cp = ch.codePointAt(0);
|
|
441
|
+
if (cp > 127) { wide++; continue; }
|
|
442
|
+
if (cp === 32 || cp === 9) { space++; continue; }
|
|
443
|
+
if (cp >= 48 && cp <= 57) { digits++; continue; }
|
|
444
|
+
if ((cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122)) { letters++; continue; }
|
|
445
|
+
punct++;
|
|
404
446
|
}
|
|
405
|
-
return ascii / 4 + nonAscii;
|
|
406
|
-
}
|
|
407
447
|
|
|
448
|
+
// Newlines land in `punct` (they are not 32 or 9), which is the right rate for them.
|
|
449
|
+
//
|
|
450
|
+
// The SPACE rate is the delicate one: a run of indentation costs about a token per two columns, but a
|
|
451
|
+
// single space between English words merges into the following word almost for free. Charging every
|
|
452
|
+
// space at the indentation rate made prose read 34% high — a worse error than the undercount it fixed,
|
|
453
|
+
// because prose is what the model WRITES back.
|
|
454
|
+
//
|
|
455
|
+
// So spaces are priced at a third of a token each: cheap enough for prose, and still enough that a
|
|
456
|
+
// deeply indented code block is not free.
|
|
457
|
+
return letters / 4.2 + digits / 3.0 + punct / 2.7 + space / 3.0 + wide;
|
|
458
|
+
}
|
|
408
459
|
export function estimateTokens(text) {
|
|
409
460
|
const s = String(text ?? '');
|
|
410
461
|
if (s.length <= TOK_CACHE_MAX_LEN) {
|
|
411
462
|
const hit = tokCache.get(s);
|
|
412
463
|
if (hit !== undefined) return hit;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (ch.codePointAt(0) <= 127) ascii++;
|
|
417
|
-
else nonAscii++;
|
|
418
|
-
}
|
|
419
|
-
const out = Math.ceil(ascii / 4) + nonAscii;
|
|
464
|
+
// Rounded UP from the same BPE-aware estimate the streaming counter uses. Both must agree: a body
|
|
465
|
+
// measured one way and a stream measured another is how a session ends up reporting two numbers.
|
|
466
|
+
const out = Math.ceil(estimateBpe(s));
|
|
420
467
|
if (tokCache.size >= TOK_CACHE_MAX) {
|
|
421
468
|
// Drop the oldest half in one go: cheaper than one-at-a-time and keeps the
|
|
422
469
|
// recently measured entries that a per-entry eviction would churn.
|
|
@@ -426,13 +473,9 @@ export function estimateTokens(text) {
|
|
|
426
473
|
tokCache.set(s, out);
|
|
427
474
|
return out;
|
|
428
475
|
}
|
|
429
|
-
// Long body:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (ch.codePointAt(0) <= 127) ascii++;
|
|
433
|
-
else nonAscii++;
|
|
434
|
-
}
|
|
435
|
-
return Math.ceil(ascii / 4) + nonAscii;
|
|
476
|
+
// Long body: measured directly, with the SAME estimator as the cached path. The two used to differ, so
|
|
477
|
+
// a long message and a short one were priced by different formulas.
|
|
478
|
+
return Math.ceil(estimateBpe(s));
|
|
436
479
|
}
|
|
437
480
|
|
|
438
481
|
// Estimate tokens for the MESSAGES only. The system prompt and tool definitions are
|
|
@@ -457,13 +500,52 @@ export function estimateMessageTokens(m, cfg) {
|
|
|
457
500
|
if (!m) return 0;
|
|
458
501
|
let total = estimateTokens(m.role || '');
|
|
459
502
|
const c = m.content;
|
|
460
|
-
|
|
503
|
+
if (typeof c === 'string') {
|
|
504
|
+
total += estimateTokens(c);
|
|
505
|
+
} else if (c && typeof c === 'object' && Array.isArray(c.media)) {
|
|
506
|
+
total += estimateMediaContentTokens(c);
|
|
507
|
+
} else {
|
|
508
|
+
total += estimateTokens(JSON.stringify(c || ''));
|
|
509
|
+
}
|
|
461
510
|
if (m.toolCalls) for (const tc of m.toolCalls) {
|
|
462
511
|
total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
|
|
463
512
|
}
|
|
464
513
|
return total;
|
|
465
514
|
}
|
|
466
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Tokens for a content block that carries media (an image read from disk, a screenshot).
|
|
518
|
+
*
|
|
519
|
+
* AN IMAGE IS NOT PRICED BY ITS BASE64 LENGTH. `JSON.stringify` on the same object counts every base64
|
|
520
|
+
* character as ordinary text — a 1 MB PNG is ~1.3M characters, which this estimator would price at
|
|
521
|
+
* roughly 400k tokens — while a provider charges by the image's PIXEL dimensions, usually one to two
|
|
522
|
+
* thousand. That is the direction that matters: the gauge would read hundreds of thousands too high, and
|
|
523
|
+
* auto-compaction would fire on a history that was never close to full.
|
|
524
|
+
*
|
|
525
|
+
* The text beside the image is priced normally; each image gets a flat estimate derived from the common
|
|
526
|
+
* tiling rule (a 512x512 tile ≈ 765 tokens, a 1568px max edge ≈ 1100 tokens). Exactness is impossible
|
|
527
|
+
* without decoding the image, and it does not matter here: images are rare, and being within a few
|
|
528
|
+
* hundred tokens beats being wrong by a hundred thousand.
|
|
529
|
+
*/
|
|
530
|
+
function estimateMediaContentTokens(c) {
|
|
531
|
+
let total = estimateTokens(String(c.text == null ? '' : c.text));
|
|
532
|
+
for (const m of c.media || []) {
|
|
533
|
+
if (!m || m.type !== 'image') continue;
|
|
534
|
+
// A thumbnail-sized image costs a tile; a full screenshot costs four. Read the dimensions from the
|
|
535
|
+
// metadata when the reader recorded them, and fall back to the large case otherwise — overestimating
|
|
536
|
+
// an image is far safer than under, because it makes compaction fire EARLY rather than late.
|
|
537
|
+
const w = Number(m.width) || 0;
|
|
538
|
+
const h = Number(m.height) || 0;
|
|
539
|
+
if (w > 0 && h > 0) {
|
|
540
|
+
const tiles = Math.max(1, Math.ceil(w / 512) * Math.ceil(h / 512));
|
|
541
|
+
total += Math.min(4, tiles) * 765;
|
|
542
|
+
} else {
|
|
543
|
+
total += 1100;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return total;
|
|
547
|
+
}
|
|
548
|
+
|
|
467
549
|
// ---- per-message token memo -----------------------------------------------
|
|
468
550
|
//
|
|
469
551
|
// Message objects -> { sig, tok }. WeakMap, so a message dropped from the history
|
|
@@ -501,7 +583,11 @@ function msgSignature(m) {
|
|
|
501
583
|
if (typeof c === 'string') s = 's' + c.length;
|
|
502
584
|
else if (c == null) s = 'n';
|
|
503
585
|
else if (Array.isArray(c)) s = 'a' + c.length;
|
|
504
|
-
else
|
|
586
|
+
else if (typeof c === 'object' && Array.isArray(c.media)) {
|
|
587
|
+
// A media block: its signature is the text plus one marker per image. The base64 payload itself is
|
|
588
|
+
// NOT hashed — it is the expensive part, and it never changes once the message exists.
|
|
589
|
+
s = 'm' + String(c.text == null ? '' : c.text).length + 'x' + c.media.length;
|
|
590
|
+
} else s = ''; // unknown shape: never trust a memo
|
|
505
591
|
if (m.toolCalls) s += 't' + m.toolCalls.length;
|
|
506
592
|
return s;
|
|
507
593
|
}
|
|
@@ -586,13 +672,43 @@ export class TokenLedger {
|
|
|
586
672
|
}
|
|
587
673
|
}
|
|
588
674
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
675
|
+
/**
|
|
676
|
+
* The overhead a request carries BEYOND its messages: the system prompt plus the tool definitions.
|
|
677
|
+
* Passed the assembled system text so the number matches what is actually sent.
|
|
678
|
+
*
|
|
679
|
+
* `cfg.toolDefTokens` is the REAL cost of the tool definitions, measured once by whoever built them.
|
|
680
|
+
* This used to charge a flat `n * 8` per tool, which is short by an order of magnitude: a tool's
|
|
681
|
+
* schema is its name, a paragraph of description and a nested parameters object, and measured here the
|
|
682
|
+
* 68 registered tools come to about 15 900 tokens — 29 times what 8 apiece reported.
|
|
683
|
+
*
|
|
684
|
+
* The discrepancy matters because this number is part of the context gauge. A gauge that omits ~15k of
|
|
685
|
+
* every request understates usage by that much per turn, and the user sees the provider's real figure
|
|
686
|
+
* disagree with the app's by a wide margin.
|
|
687
|
+
*
|
|
688
|
+
* The `n * 8` remains only as a FALLBACK for a caller that has not measured its definitions; it is
|
|
689
|
+
* better than nothing and is the old behaviour, exactly.
|
|
690
|
+
*/
|
|
592
691
|
export function estimateRequestOverhead(systemText, cfg) {
|
|
593
692
|
let total = estimateTokens(systemText || '');
|
|
693
|
+
const measured = cfg && Number.isFinite(cfg.toolDefTokens) ? cfg.toolDefTokens : null;
|
|
694
|
+
if (measured != null) return total + measured;
|
|
594
695
|
const n = (cfg && Array.isArray(cfg.toolFilter)) ? cfg.toolFilter.length : 0;
|
|
595
|
-
if (n) total += n * 8; // rough per-tool-definition overhead
|
|
696
|
+
if (n) total += n * 8; // rough per-tool-definition overhead, when nothing measured it
|
|
697
|
+
return total;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The token cost of a set of tool definitions, measured the way they are actually sent.
|
|
702
|
+
*
|
|
703
|
+
* Serialised as `{ name, description, parameters }` — the three fields the provider receives — and
|
|
704
|
+
* priced with the same estimator the gauge uses, so the number is comparable with everything else.
|
|
705
|
+
*/
|
|
706
|
+
export function measureToolDefTokens(tools) {
|
|
707
|
+
let total = 0;
|
|
708
|
+
for (const t of tools || []) {
|
|
709
|
+
if (!t || !t.name) continue;
|
|
710
|
+
total += estimateTokens(JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }));
|
|
711
|
+
}
|
|
596
712
|
return total;
|
|
597
713
|
}
|
|
598
714
|
|
package/src/tool-result.js
CHANGED
|
@@ -274,6 +274,54 @@ export function applyTrim(messages, plan, sessionId) {
|
|
|
274
274
|
// so a caller can skip its UI event entirely.
|
|
275
275
|
export function trimToolResults(messages, opts = {}) {
|
|
276
276
|
const plan = planTrim(messages, opts.keepRatio, opts.mustKeep);
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
const mediaPlan = planMediaTrim(messages, opts.mediaKeep ?? 2);
|
|
278
|
+
if (!plan.elide.length && !mediaPlan.elide.length) return null;
|
|
279
|
+
const applied = plan.elide.length
|
|
280
|
+
? applyTrim(messages, plan, opts.sessionId)
|
|
281
|
+
: { elided: 0, elidedBytes: 0, kept: plan.keep.length, keptBytes: plan.keepBytes };
|
|
282
|
+
const mediaApplied = applyMediaTrim(messages, mediaPlan, opts.sessionId);
|
|
283
|
+
return {
|
|
284
|
+
...applied,
|
|
285
|
+
elided: applied.elided + mediaApplied.elided,
|
|
286
|
+
elidedBytes: applied.elidedBytes + mediaApplied.elidedBytes,
|
|
287
|
+
plan,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- media results ----------------------------------------------------------
|
|
292
|
+
// A media tool result keeps its base64 payload IN the history object — and unlike
|
|
293
|
+
// a string body, planTrim cannot elide it, so every screenshot was re-serialized
|
|
294
|
+
// into every request body, every session save and every cache-prefix walk until a
|
|
295
|
+
// full compaction dropped it. Keep only the newest `mediaKeep` media results;
|
|
296
|
+
// older ones spill their text (if any) and lose the bytes, same contract as the
|
|
297
|
+
// string path: saved to disk first, pointer left behind.
|
|
298
|
+
|
|
299
|
+
function planMediaTrim(messages, keep) {
|
|
300
|
+
const idx = [];
|
|
301
|
+
if (Array.isArray(messages)) {
|
|
302
|
+
for (let i = 0; i < messages.length; i++) {
|
|
303
|
+
const m = messages[i];
|
|
304
|
+
if (m && m.role === 'tool' && m.content && typeof m.content === 'object'
|
|
305
|
+
&& Array.isArray(m.content.media) && m.content.media.length) idx.push(i);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const elide = idx.slice(0, Math.max(0, idx.length - Math.max(0, keep)));
|
|
309
|
+
return { elide };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function applyMediaTrim(messages, mediaPlan, sessionId) {
|
|
313
|
+
const out = { elided: 0, elidedBytes: 0 };
|
|
314
|
+
for (const i of mediaPlan.elide) {
|
|
315
|
+
const m = messages[i];
|
|
316
|
+
let bytes = 0;
|
|
317
|
+
for (const part of m.content.media) bytes += (part && part.data ? part.data.length : 0);
|
|
318
|
+
const text = String(m.content.text || '');
|
|
319
|
+
const spilled = spillResult(sessionId, `trim-${m.toolCallId || i}`, text || '[media only]');
|
|
320
|
+
m.content = `[Media result elided to save context — ${fmtBytes(bytes)} of image data from ${m.name || 'tool'}`
|
|
321
|
+
+ (spilled ? `; the description was saved to: ${spilled.file}` : '; the description could not be saved')
|
|
322
|
+
+ '. Re-run the tool if you need the image again.]';
|
|
323
|
+
out.elided++;
|
|
324
|
+
out.elidedBytes += bytes;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
279
327
|
}
|