@nanhara/hara 0.109.0 → 0.109.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/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.109.2 — long paste no longer freezes the input box
9
+
10
+ - **The input box now draws a bottom-anchored viewport, not every wrapped row.** A long multi-line
11
+ paste (a spec, a stack trace, a design brief) wraps to hundreds of rows; rendering *all* of them on
12
+ every keystroke floods ink's layout+diff and the box appears frozen ("卡着" — you type and nothing
13
+ moves). It now renders at most ~14 rows around the cursor with `⋯ N more lines above/below` markers,
14
+ so a huge paste stays smooth and the box stays a sane height. (This closes the freeze that 0.109.0's
15
+ "paste inserts as real multi-line text" could reach — the two ship as a pair.) A tip if a paste was
16
+ meant to *launch* a skill: a plain long message relies on the model to pick the skill; prefixing the
17
+ explicit command (e.g. `/design <brief>`) enters that mode deterministically and immediately.
18
+
19
+ ## 0.109.1 — prompt caching + dynamic compaction (the "why is it slow" fix)
20
+
21
+ - **Prompt caching, finally on.** Every turn used to re-send *and re-process* the whole prompt —
22
+ system + all tool definitions + the entire growing history — with **no cache breakpoints**, so the
23
+ model re-billed and re-crunched everything from scratch each turn. The longer the session, the
24
+ slower and pricier each reply. hara now sets Anthropic `cache_control` breakpoints on the static
25
+ prefix (system, which covers tools+system in cache order) and two rolling points at the message tail,
26
+ so each turn reads the unchanged prefix **from cache** (~10% the cost, and far lower time-to-first-
27
+ token). This is the single biggest latency win as history grows. (Cache engages once the prefix
28
+ passes Anthropic's ~1024-token minimum — i.e. any real coding session.)
29
+ - **Auto-compaction now actually fires on big-window models.** It triggered at 85% of the context
30
+ window — but on a 1M-token model that's **850k tokens**, a size a session drags sluggishly toward and
31
+ realistically never hits, so it never compacted and the prompt just kept bloating. Added a **dynamic
32
+ absolute cap**: compact once the live context passes ~200k tokens *regardless* of window size (either
33
+ trigger — % of window OR the cap — fires). Override with `HARA_AUTO_COMPACT_TOKENS`; opt out as before
34
+ with `autoCompact: false` / `HARA_AUTO_COMPACT=0`.
35
+ - Studied codex (Rust `prompt_cache_key` + a 1000-char paste-fold threshold) and cc-haha (Claude Code's
36
+ session-stable cache TTL) to land the breakpoint layout and keep the TTL steady within a session.
37
+
8
38
  ## 0.109.0 — real multi-line input + Windows shell
9
39
 
10
40
  - **Pasted multi-line text is now real, editable text in the box** — not a `[Paste]` token, not an
@@ -3,6 +3,11 @@
3
3
  // which reuses the manual /compact path; this just decides *when* to fire.
4
4
  /** Auto-compact once the last turn used ≥ this % of the model's context window. */
5
5
  export const AUTO_COMPACT_PCT = 85;
6
+ /** Dynamic absolute ceiling. On a 1M-window model, 85% == 850k tokens — a size a session drags for a
7
+ * long, sluggish while before ever reaching, so the %-trigger effectively never fires and every turn
8
+ * re-sends a bloated prompt. This cap makes auto-compaction actually engage at a snappy working size
9
+ * regardless of how large the window is. Overridable via `HARA_AUTO_COMPACT_TOKENS`. */
10
+ export const AUTO_COMPACT_TOKEN_CAP = 200_000;
6
11
  /** The compaction brief (shared by /compact and auto-compaction). Eight sections, mirroring Claude
7
12
  * Code's AU2 template — the two that matter most beyond the obvious: **All user messages** (the
8
13
  * user's own words survive verbatim, so however hard the history is squeezed, intent never drifts)
@@ -47,3 +52,9 @@ export function buildFileRestore(paths, readFn, opts) {
47
52
  export function shouldAutoCompact(ctxPct, historyLen, autoCompact, threshold = AUTO_COMPACT_PCT) {
48
53
  return autoCompact && historyLen >= 4 && ctxPct >= threshold;
49
54
  }
55
+ /** Absolute-size companion to shouldAutoCompact: fire once the last turn's real token count crosses the
56
+ * cap. This is what makes auto-compaction engage on huge-window models, where the %-trigger sits at an
57
+ * unreachable 850k. Either trigger (this OR the %-of-window one) compacts. */
58
+ export function shouldAutoCompactTokens(lastInputTokens, historyLen, autoCompact, cap = AUTO_COMPACT_TOKEN_CAP) {
59
+ return autoCompact && historyLen >= 4 && lastInputTokens >= cap;
60
+ }
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
24
24
  import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
25
25
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
26
  import { routingProvider } from "./agent/route.js";
27
- import { shouldAutoCompact, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
27
+ import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
28
28
  import { recentTouched } from "./agent/touched.js";
29
29
  import { INTERJECT_PREFIX } from "./agent/reminders.js";
30
30
  import { checkForUpdate } from "./update-check.js";
@@ -770,10 +770,16 @@ async function compactConversation(provider, history, meta, stats) {
770
770
  * doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
771
771
  * a one-line status. Returns true if it compacted. */
772
772
  async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
773
- const pct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
774
- if (!shouldAutoCompact(pct, history.length, cfg.autoCompact))
773
+ const lastInput = stats.lastInput ?? 0;
774
+ const pct = bar.ctxPctFor(cfg.model, lastInput);
775
+ // Two triggers, whichever hits first: % of window (small-window models) OR an absolute token cap
776
+ // (huge-window models, where 85% is an unreachable 850k). Cap is overridable via env.
777
+ const cap = Number(process.env.HARA_AUTO_COMPACT_TOKENS) || AUTO_COMPACT_TOKEN_CAP;
778
+ const overPct = shouldAutoCompact(pct, history.length, cfg.autoCompact);
779
+ const overCap = shouldAutoCompactTokens(lastInput, history.length, cfg.autoCompact, cap);
780
+ if (!overPct && !overCap)
775
781
  return false;
776
- notify(`✻ Auto-compacting conversation (context ${pct}% full)…`);
782
+ notify(`✻ Auto-compacting conversation (context ${pct}% full, ~${Math.round(lastInput / 1000)}k tok)…`);
777
783
  const summary = await compactConversation(provider, history, meta, stats);
778
784
  notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
779
785
  return !!summary;
@@ -51,6 +51,38 @@ export function toAnthropic(history) {
51
51
  }
52
52
  return msgs;
53
53
  }
54
+ const CACHE = { type: "ephemeral" };
55
+ /** Attach Anthropic prompt-cache breakpoints so each turn re-reads the static prefix and the
56
+ * conversation prefix FROM CACHE instead of re-billing + re-processing the whole prompt — the single
57
+ * biggest latency + cost win as history grows (uncached, a long session pays full input every turn).
58
+ *
59
+ * Anthropic caps breakpoints at 4 and orders the cache prefix `tools → system → messages`, so a mark
60
+ * on `system` already caches tools+system (the big static block) in one shot. We then spend up to two
61
+ * rolling marks near the message tail: the last message (so THIS turn's full prefix is written) and one
62
+ * ~2 back (so the NEXT turn — which appends new messages — still finds a long cached prefix to read).
63
+ * Mutates `messages` in place (toAnthropic hands us a fresh array each turn); returns the request-shaped
64
+ * system. Exported pure for tests. */
65
+ export function applyCacheControl(system, messages) {
66
+ const mark = (m) => {
67
+ if (typeof m.content === "string") {
68
+ if (m.content.length)
69
+ m.content = [{ type: "text", text: m.content, cache_control: CACHE }];
70
+ }
71
+ else if (m.content.length) {
72
+ m.content[m.content.length - 1].cache_control = CACHE;
73
+ }
74
+ };
75
+ const idxs = new Set();
76
+ if (messages.length)
77
+ idxs.add(messages.length - 1);
78
+ if (messages.length >= 3)
79
+ idxs.add(messages.length - 3);
80
+ for (const i of idxs)
81
+ mark(messages[i]);
82
+ // Only cache system when it's substantial enough to matter (an empty text block would 400).
83
+ const cachedSystem = system ? [{ type: "text", text: system, cache_control: CACHE }] : system;
84
+ return { system: cachedSystem, messages };
85
+ }
54
86
  /** Anthropic models whose only valid `thinking` setting is `{type: "adaptive"}` — they reject any
55
87
  * explicit `budget_tokens`. We detect them by id family so "off"/low/high still degrade gracefully
56
88
  * (we just omit the field or stay on adaptive instead of sending a 400-triggering body). */
@@ -84,13 +116,14 @@ export function createAnthropicProvider(opts) {
84
116
  model: opts.model,
85
117
  async turn({ system, history, tools, onText, onReasoning, signal }) {
86
118
  const thinking = buildThinkingParam(opts.model, opts.reasoningEffort);
119
+ const { system: cachedSystem, messages } = applyCacheControl(system, toAnthropic(history));
87
120
  const stream = client.messages.stream({
88
121
  model: opts.model,
89
122
  max_tokens: 32000,
90
123
  ...(thinking ? { thinking } : {}),
91
- system,
124
+ system: cachedSystem,
92
125
  tools: tools,
93
- messages: toAnthropic(history),
126
+ messages,
94
127
  }, { signal });
95
128
  stream.on("text", onText);
96
129
  if (onReasoning)
@@ -231,6 +231,26 @@ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
231
231
  }
232
232
  return nodes;
233
233
  }
234
+ /** Max input rows drawn at once. A long multi-line paste (a spec, a stack trace, a design brief) wraps
235
+ * to hundreds/thousands of rows; rendering them ALL every keystroke floods ink's layout+diff and the
236
+ * box appears frozen ("卡着"). So we draw a bottom-anchored viewport (codex-style) around the cursor. */
237
+ export const MAX_INPUT_ROWS = 14;
238
+ /** The [first, last) slice of rows to render so the cursor stays visible without drawing the whole
239
+ * input. Bottom-anchored: when the cursor is at the end (typing), you see the last MAX rows; when
240
+ * editing mid-text, the cursor's row + a little context below stays on screen. Exported pure for tests. */
241
+ export function windowRows(rowCount, cursorRow, max = MAX_INPUT_ROWS) {
242
+ if (rowCount <= max)
243
+ return { first: 0, last: rowCount };
244
+ const last = Math.min(rowCount, Math.max(cursorRow + 2, max));
245
+ return { first: Math.max(0, last - max), last };
246
+ }
247
+ /** Index of the wrapped row that holds the cursor (last row if past the end). */
248
+ export function cursorRowIndex(rows, cursor) {
249
+ for (let i = 0; i < rows.length; i++)
250
+ if (cursor >= rows[i].start && cursor <= rows[i].end)
251
+ return i;
252
+ return Math.max(0, rows.length - 1);
253
+ }
234
254
  /** The prompt: gutter + wrapped input rows (or a placeholder when empty). Each wrapped continuation
235
255
  * row is indented under the gutter so the text column is stable. Memoized so it only re-renders when
236
256
  * the value/cursor/width/gutter actually change (not when unrelated status ticks over). */
@@ -240,7 +260,11 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
240
260
  if (value.length === 0) {
241
261
  return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: gutter }), _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })] }));
242
262
  }
243
- return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: i === 0 ? gutter : " " }), _jsx(Text, { children: renderRow(value, row, cursor, true, i === rows.length - 1, `r${i}_`) })] }, i))) }));
263
+ const { first, last } = windowRows(rows.length, cursorRowIndex(rows, cursor));
264
+ return (_jsxs(Box, { flexDirection: "column", children: [first > 0 && _jsx(Text, { dimColor: true, children: ` ⋯ ${first} more line${first > 1 ? "s" : ""} above` }), rows.slice(first, last).map((row, k) => {
265
+ const i = first + k;
266
+ return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: i === 0 ? gutter : " " }), _jsx(Text, { children: renderRow(value, row, cursor, true, i === rows.length - 1, `r${i}_`) })] }, i));
267
+ }), last < rows.length && _jsx(Text, { dimColor: true, children: ` ⋯ ${rows.length - last} more line${rows.length - last > 1 ? "s" : ""} below` })] }));
244
268
  });
245
269
  /** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx),
246
270
  * with an @path popup. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.109.0",
3
+ "version": "0.109.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"