@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.
Files changed (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. 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
- const s = String(text ?? '');
400
- let ascii = 0, nonAscii = 0;
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
- if (ch.codePointAt(0) <= 127) ascii++;
403
- else nonAscii++;
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
- if (hit !== undefined) return hit;
414
- let ascii = 0, nonAscii = 0;
415
- for (const ch of s) {
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: walk it. No memoise, no map churn.
430
- let ascii = 0, nonAscii = 0;
431
- for (const ch of s) {
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
- total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
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 s = ''; // unknown shape: never trust a memo
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
- // The overhead a request carries BEYOND its messages: the system prompt plus the
590
- // tool definitions. Passed the assembled system text so the number matches what is
591
- // actually sent.
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
 
@@ -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
- if (!plan.elide.length) return null;
278
- return { ...applyTrim(messages, plan, opts.sessionId), plan };
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
  }