@nanhara/hara 0.123.1 → 0.124.1

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,55 @@ 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.124.1 — 2026-07-16 — Windows native private-state and file-identity portability
9
+
10
+ - **Windows private state works in both Node and the native Bun executable.** Exclusive staging creation now
11
+ uses the portable `wx`/`CREATE_NEW` contract instead of passing Bun numeric flags that could surface a false
12
+ `ENOENT`. Config, credentials, gateway state, discovery records, and other private atomic writers retain
13
+ create-if-absent publication, hard-link fencing, fsync, and fail-closed alias checks.
14
+ - **Descriptor-to-path identity checks follow the host's real guarantees.** Windows Node may report a
15
+ volume-derived `dev` from `fstat` but `dev=0` from `lstat` for the exact same NTFS file. Hara now correlates
16
+ an already-open descriptor with its already-bounded path using the stable file id and link count, while
17
+ POSIX still requires device, inode, and owner-only mode. Arbitrary path-to-path protected-file detection
18
+ continues to require device + inode, so the portability fix does not weaken hard-link protection.
19
+ - **The release gate now exercises the reported failure on a real Windows runtime.** CI installs the pinned
20
+ Bun 1.3.9 x64-baseline runtime, runs portable file-state contracts, compiles `hara.exe`, and executes the
21
+ hostile-cwd standalone smoke including an isolated `hara doctor`. Bounded failure annotations keep future
22
+ Windows-only regressions diagnosable without exposing credentials. Production dependency audits retry a
23
+ transient registry failure only within a fixed bound and still require a real successful audit result.
24
+ - Windows users on 0.124.0 should upgrade with `npm i -g @nanhara/hara@0.124.1`; Desktop releases that bundle
25
+ a sidecar should pin this patch or newer.
26
+
27
+ ## 0.124.0 — 2026-07-16 — task-safe interaction, bounded context, and private-state hardening
28
+
29
+ - **Idle conversation no longer hijacks an unfinished task.** Ordinary input starts a new execution while
30
+ keeping the conversation thread; explicit `继续` / `continue` / `resume` / `go on` resumes the paused task,
31
+ and `/new` creates a visible task boundary. While a turn is live, Enter still steers it; `/next <message>`
32
+ queues a separate next task.
33
+ - **Accepted Desktop steering is crash-safe and exactly-once.** `session.steer` persists a bounded pending
34
+ inbox entry before ACK, writes the projected transcript before delivery, then marks it consumed. Legacy
35
+ audit entries are never replayed, a full pending inbox applies backpressure, and secrets remain redacted.
36
+ - **Every model request has a total context boundary.** Oversized historical user/assistant/tool payloads,
37
+ tool-call arguments, and old images are normalized in a provider-only snapshot without rewriting durable
38
+ history. A context-overflow response gets one tighter same-model retry before configured fallback.
39
+ - **Compaction is now a bounded execution checkpoint.** The summarizer receives a guarded source snapshot,
40
+ emits structured goals/constraints/decisions/verification/files/errors/checkpoint/blockers/next action,
41
+ and keeps the last three user-turn groups as an anti-drift anchor instead of copying every user message
42
+ into the summary. CLI and Serve use the same contract and restore current touched files.
43
+ - **Self-evolution is explicit and auditable.** `/evolve status|now` exposes the mode and manual curation;
44
+ classic and TUI exits share proactive reflection. Candidate observations go to logs, only stable evidence-
45
+ backed facts/preferences enter memory, verified repeatable procedures may become skills, and autonomous
46
+ code/config/permission/system-prompt mutation is explicitly outside the feature.
47
+ - **Credential files share one no-alias storage boundary.** Desk registration, identity profiles, Qwen OAuth,
48
+ Weixin credentials/cursors/context tokens, legacy org enrollment, and global config mutations now use
49
+ symlink-free 0700 directories, bounded no-follow reads, hard-link rejection, 0600 fsynced staging, and
50
+ identity-checked compare-and-swap publication. Preseeded aliases fail closed without changing external file
51
+ bytes or permissions; legacy `org.json` archival/removal is bound to the exact verified inode.
52
+ - **Text tools no longer rewrite undecodable bytes.** Existing-file preflight for `edit_file`, `write_file`,
53
+ and single/multi-file `apply_patch` uses fatal UTF-8 decoding and keeps the NUL/binary refusal. Invalid input
54
+ fails before the first commit with its original bytes intact, while a valid UTF-8 BOM is preserved across an
55
+ edit instead of being silently stripped.
56
+
8
57
  ## 0.123.1 — 2026-07-15 — restore default TUI keyboard input
9
58
 
10
59
  - **The bracketed-paste proxy now resumes the real terminal stream.** The main TUI closes its temporary
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  - **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
15
15
  - **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images where the platform has a byte-upload API**, resumable per-chat sessions, project/agent roaming, bounded per-thread queues, and approval-gated group automations. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
16
16
  - **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only investigation → the model submits its plan via `exit_plan` → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
17
- - **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
17
+ - **Persistent memory + auditable self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting and can curate evidence-backed facts/preferences plus verified reusable skills. `/evolve status|now` shows or runs the bounded curation policy; it never grants permission to rewrite product code, permissions, config, or system prompts. Inspect/consolidate with **`hara memory show`** and **`hara memory distill`**. Lexical-first by design — semantic search is opt-in, never required.
18
18
  - **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
19
19
  - **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. It is a trusted extension outside Hara's protected-file boundary: every interactive call requires confirmation, and non-interactive use is disabled by default.
20
20
  - **Honest under a slow network** — a live "waiting for the model… Ns" status, a stall watchdog that
@@ -195,9 +195,10 @@ stderr and missing/invalid output exits non-zero. `--role reviewer` resolves loc
195
195
  uses the portable global persona in the current project, and `--role shop:reviewer` runs at that registered
196
196
  project home. Each form enforces the role's persona, model, `allowTools`/`denyTools`, and `readOnly` policy.
197
197
 
198
- Inside the REPL: `/help` `/init` `/tools` `/model` `/approval` `/org` `/plan` `/roles` `/task` `/usage`
198
+ Inside the REPL: `/help` `/init` `/tools` `/model` `/approval` `/org` `/plan` `/roles` `/task` `/continue` `/new` `/evolve` `/usage`
199
199
  `/doctor` `/sessions` `/undo` `/compact` `/recall` `/reset` `/exit` (type `/`+Tab to complete). `/task`
200
- shows the active execution record; `/task clear` drops it without deleting the conversation. Type `@` + Tab
200
+ shows the active execution record; `/task clear` drops it without deleting the conversation, and `/new` starts
201
+ a new task while retaining useful thread context. Type `@` + Tab
201
202
  to attach a file (fuzzy, walks subdirectories).
202
203
 
203
204
  The interactive REPL is an **ink TUI**: a bordered **input box pinned at the bottom** — session name in
@@ -257,11 +258,12 @@ by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-
257
258
  vision model into **actionable** output — interactive elements + positions (pass `focus` to target what you're after) — so even a text-only main model can click.
258
259
  **Sessions and task execution**: conversations are saved automatically — `-c` / `--resume <id>` or
259
260
  `hara resume <id>` to continue, `hara sessions` to list, `hara export [id] [--out file]` to render one as a
260
- Markdown transcript. The current task is persisted separately with stable task/turn identity and resumes as
261
- paused rather than being inferred from chat text; `/task` inspects it and `/task clear` leaves the transcript
262
- intact. Type-ahead input steers the exact live turn. Desktop/serve clients can use `session.steer` with
263
- `expectedTurnId`, and may pass `newTask: true` to `session.send` when an unfinished resumed task should be
264
- replaced explicitly. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary
261
+ Markdown transcript. The current task is persisted separately with stable task/turn identity and recovers as
262
+ paused rather than being inferred from chat text. At idle, ordinary input starts a new task; an explicit
263
+ `继续` / `continue` / `resume` / `go on` resumes the paused objective, while `/new` forces a clean task boundary.
264
+ Type-ahead Enter steers the exact live turn; `/next <message>` queues a separate task after it. Desktop/serve
265
+ clients use `session.steer` with `expectedTurnId` (durable before ACK) and may pass `newTask: true` to force a
266
+ new execution. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary
265
267
  installs.
266
268
  **MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch); their tools appear to the agent as `mcp__<server>__<tool>`. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive tool call requires confirmation (even in `full-auto`), and non-interactive runs disable them by default; reviewed automation can explicitly opt in before launch with `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
267
269
  **Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
@@ -334,7 +336,8 @@ read-only **`grep`** / **`glob`** / **`ls`** / **`web_fetch`** — behind a huma
334
336
  dangerous ones unless `-y`. Read-only tools run in parallel within a turn, and edits print a
335
337
  **colored diff** of what changed. Shell output streams live; press **Esc** to interrupt a running
336
338
  turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review`**, and **`/commit`** close the change → review → commit loop without leaving the prompt.
337
- - **Type-ahead steering**: keep typing while hara works your message is held, then **folded into the next model call** (not deferred to a new turn), so a clarification or "also do X" course-corrects the task already in flight (codex-style). Messages typed after the final step start a fresh turn; **Esc** drops the queue and stops.
339
+ - **Explicit live steering vs next-task queue**: keep typing while hara works and press Enter to fold a clarification into the next model call; use `/next <message>` to queue a separate task behind the live one. Accepted Desktop steering is persisted before ACK; **Esc** drops the local queue and stops.
340
+ - **Bounded context + checkpoint compaction**: each provider call receives a non-destructive budgeted snapshot (old tool payloads/images cannot monopolize context). Overflow retries once with a tighter snapshot; `/compact` creates a structured execution checkpoint and retains the latest three user-turn groups plus current touched-file content.
338
341
  - **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
339
342
  - **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
340
343
  - **Explicit workspace boundary**: launching at Home does not inventory its directories or permit coding mutations. Start Hara from a concrete project, or pass `hara --cwd /path/to/project`, to enable search, `@` completion, shell/external agents, and file edits; explicitly named single-file reads remain available at Home.
@@ -8,31 +8,111 @@ export const AUTO_COMPACT_PCT = 85;
8
8
  * re-sends a bloated prompt. This cap makes auto-compaction actually engage at a snappy working size
9
9
  * regardless of how large the window is. Overridable via `HARA_AUTO_COMPACT_TOKENS`. */
10
10
  export const AUTO_COMPACT_TOKEN_CAP = 200_000;
11
- /** The compaction brief (shared by /compact and auto-compaction). Eight sections, mirroring Claude
12
- * Code's AU2 template — the two that matter most beyond the obvious: **All user messages** (the
13
- * user's own words survive verbatim, so however hard the history is squeezed, intent never drifts)
14
- * and **Key technical concepts** (the frameworks/APIs in play, so the next turn doesn't re-derive
15
- * the stack). Lives here (not index.ts) so tests can pin the structure. */
16
- export const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
17
- "loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
18
- "then output ONLY the summary under these exact headings:\n" +
19
- "1. Goal the user's overall intent, in their own framing.\n" +
20
- "2. Key technical concepts — the frameworks, tools, APIs, and domain ideas central to the work.\n" +
21
- "3. Key decisions — choices made and why (so they aren't relitigated).\n" +
22
- "4. Files & code — files created/changed and the important snippets, with why each matters.\n" +
23
- "5. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
24
- "6. Current state — what works now / what is verified.\n" +
25
- "7. All user messages — EVERY user message so far (excluding tool results), verbatim and in order; abbreviate only huge pasted blobs with […]. These are the ground truth of intent.\n" +
26
- "8. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
27
- "Be specific and concrete. Drop the <analysis>; output only the headed summary.";
11
+ import { historyChars, prepareHistoryForModel } from "./context-budget.js";
12
+ export const COMPACTION_SOURCE_CHARS = 220_000;
13
+ export const COMPACTION_RECENT_CHARS = 72_000;
14
+ export const COMPACTION_SUMMARY_CHARS = 40_000;
15
+ export const COMPACTION_RECENT_USER_TURNS = 3;
16
+ export const COMPACTION_HEADINGS = [
17
+ "Goal and latest request",
18
+ "Constraints and preferences",
19
+ "Decisions and exact identifiers",
20
+ "Completed and verified",
21
+ "Files and artifacts",
22
+ "Failures and corrections",
23
+ "Current execution checkpoint",
24
+ "Pending work and blockers",
25
+ "Next concrete action",
26
+ ];
27
+ /** The compaction brief is bounded and checkpoint-oriented. Recent turns are preserved separately, so asking
28
+ * the model to repeat every user message would waste the very context compaction is meant to recover. */
29
+ export const COMPACT_SYSTEM = "Create a bounded execution checkpoint for another coding agent. Recent turns will be retained separately, so DO NOT copy the full transcript or list every user message. " +
30
+ "Preserve exact file paths, commands, versions, task/turn IDs, error strings, API names, and numeric values when they matter. Distinguish verified facts from assumptions. " +
31
+ "Quote only short, pointed user corrections or the latest ask when necessary. Output ONLY these exact Markdown headings, each concise and concrete:\n" +
32
+ COMPACTION_HEADINGS.map((heading) => `## ${heading}`).join("\n") + "\n" +
33
+ "Under Current execution checkpoint include the active plan/status and the last safely completed action. Under Pending work and blockers include accepted steering or unanswered questions. " +
34
+ "Under Next concrete action give exactly one immediate action. Never include secrets or credentials.";
35
+ function clipSummary(value) {
36
+ const normalized = value.replace(/\r\n?/g, "\n").trim();
37
+ if (normalized.length <= COMPACTION_SUMMARY_CHARS)
38
+ return normalized;
39
+ const marker = "\n…[hara: compaction summary truncated at its hard boundary]";
40
+ return normalized.slice(0, COMPACTION_SUMMARY_CHARS - marker.length) + marker;
41
+ }
42
+ function clipSection(value, max = 3_800) {
43
+ const normalized = value.trim();
44
+ if (normalized.length <= max)
45
+ return normalized;
46
+ const marker = "\n…[section truncated]";
47
+ return normalized.slice(0, max - marker.length) + marker;
48
+ }
49
+ /** Ensure even a weak/non-compliant summarizer leaves a predictable checkpoint shape. */
50
+ export function normalizeCompactionSummary(raw) {
51
+ const normalized = raw.replace(/\r\n?/g, "\n").trim();
52
+ const complete = COMPACTION_HEADINGS.every((heading) => normalized.includes(`## ${heading}`));
53
+ if (complete) {
54
+ const sections = COMPACTION_HEADINGS.map((heading, index) => {
55
+ const start = normalized.indexOf(`## ${heading}`) + `## ${heading}`.length;
56
+ const nextHeading = COMPACTION_HEADINGS.slice(index + 1)
57
+ .map((candidate) => normalized.indexOf(`## ${candidate}`, start))
58
+ .find((position) => position >= 0) ?? normalized.length;
59
+ return `## ${heading}\n${clipSection(normalized.slice(start, nextHeading))}`;
60
+ });
61
+ return clipSummary(sections.join("\n\n"));
62
+ }
63
+ const summary = clipSection(normalized, 8_000);
64
+ const sections = COMPACTION_HEADINGS.map((heading, index) => {
65
+ if (index === 0)
66
+ return `## ${heading}\n${summary || "No usable model summary was returned."}`;
67
+ if (heading === "Current execution checkpoint")
68
+ return `## ${heading}\nConsult the durable task state and recent preserved turns.`;
69
+ if (heading === "Next concrete action")
70
+ return `## ${heading}\nRe-read the latest preserved user request and take its next verifiable step.`;
71
+ return `## ${heading}\nNot captured by the summarizer; verify from recent preserved turns or the workspace.`;
72
+ });
73
+ return clipSummary(sections.join("\n\n"));
74
+ }
75
+ /** Bounded source snapshot for the summarizer itself; direct compaction calls bypass runAgent's guard. */
76
+ export function compactionSourceHistory(history) {
77
+ return prepareHistoryForModel(history, { model: "compaction", maxChars: COMPACTION_SOURCE_CHARS }).history;
78
+ }
79
+ /** Preserve the last few user-turn groups after the summary, with the same hard payload/image boundaries as
80
+ * ordinary model requests. This is the anti-drift anchor used by Codex/OpenClaw-style compaction. */
81
+ export function recentHistoryForCompaction(history, userTurns = COMPACTION_RECENT_USER_TURNS) {
82
+ const users = history.flatMap((message, index) => message.role === "user" ? [index] : []);
83
+ if (!users.length)
84
+ return [];
85
+ const start = users[Math.max(0, users.length - Math.max(1, userTurns))];
86
+ return prepareHistoryForModel(history.slice(start), { model: "compaction", maxChars: COMPACTION_RECENT_CHARS }).history;
87
+ }
88
+ export function compactedConversationHistory(summary, recent, restore) {
89
+ const out = [
90
+ { role: "user", content: `Execution checkpoint from older conversation (continue from current task and recent turns):\n\n${normalizeCompactionSummary(summary)}` },
91
+ ...recent,
92
+ ];
93
+ if (restore)
94
+ out.push({ role: "user", content: restore });
95
+ return out;
96
+ }
97
+ export function compactedHistoryTokenEstimate(history) {
98
+ return Math.ceil(historyChars(history) / 4);
99
+ }
28
100
  /** Working-memory notes distilled from a compaction summary — short lines that survive the history wipe
29
101
  * (stored on SessionMeta.workingSet, injected into subsequent turns). Shared by the CLI /compact path
30
102
  * and serve's session.compact. */
31
103
  export function workingSetFromSummary(s) {
104
+ const seen = new Set();
32
105
  return s
33
106
  .split("\n")
34
107
  .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
35
- .filter((l) => l.length > 3)
108
+ .filter((l) => l.length > 3 && !/^#{1,6}\s/.test(l) && !/^not captured/i.test(l) && !/^consult the durable/i.test(l))
109
+ .filter((l) => {
110
+ const key = l.toLocaleLowerCase();
111
+ if (seen.has(key))
112
+ return false;
113
+ seen.add(key);
114
+ return true;
115
+ })
36
116
  .slice(0, 12)
37
117
  .map((l) => l.slice(0, 140));
38
118
  }
@@ -0,0 +1,180 @@
1
+ import { contextWindow } from "../statusbar.js";
2
+ export const MAX_MODEL_HISTORY_CHARS = 600_000;
3
+ export const MIN_MODEL_HISTORY_CHARS = 48_000;
4
+ export const MAX_USER_ITEM_CHARS = 64_000;
5
+ export const MAX_ASSISTANT_ITEM_CHARS = 24_000;
6
+ export const MAX_TOOL_INPUT_STRING_CHARS = 12_000;
7
+ export const RECENT_IMAGE_TURNS = 2;
8
+ const GUARD_NOTE = "[Hara context guard: older/oversized context was bounded for this model request. The durable transcript is unchanged. " +
9
+ "Prefer current task state and recent messages; re-read files or ask the user before relying on an omitted exact value.]";
10
+ function jsonChars(value) {
11
+ try {
12
+ return JSON.stringify(value).length;
13
+ }
14
+ catch {
15
+ return 1_000;
16
+ }
17
+ }
18
+ export function historyChars(history) {
19
+ let total = 0;
20
+ for (const message of history) {
21
+ if (message.role === "user") {
22
+ total += message.content.length + (message.images?.reduce((sum, image) => sum + image.path.length + image.mediaType.length + 64, 0) ?? 0);
23
+ }
24
+ else if (message.role === "assistant") {
25
+ total += message.text.length + jsonChars(message.toolUses);
26
+ }
27
+ else {
28
+ total += message.results.reduce((sum, result) => sum + result.content.length + result.id.length + result.name.length + 32, 0);
29
+ }
30
+ }
31
+ return total;
32
+ }
33
+ export function modelHistoryBudget(options) {
34
+ if (options.maxChars !== undefined)
35
+ return Math.max(2_000, Math.floor(options.maxChars));
36
+ // Use a conservative blended chars/token allowance: English commonly has more, while CJK/code can have
37
+ // fewer. Reserve system/tool schema room and cap giant-window models at a responsive working set.
38
+ const gross = Math.min(MAX_MODEL_HISTORY_CHARS, Math.floor(contextWindow(options.model) * 1.5));
39
+ const overhead = (options.system?.length ?? 0) + jsonChars(options.tools ?? []);
40
+ const scale = Math.max(0.2, Math.min(1, options.budgetScale ?? 1));
41
+ return Math.max(MIN_MODEL_HISTORY_CHARS, Math.floor((gross - overhead) * scale));
42
+ }
43
+ function safeSliceEnd(value, end) {
44
+ let at = Math.max(0, Math.min(value.length, end));
45
+ if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
46
+ at--;
47
+ return value.slice(0, at);
48
+ }
49
+ function safeSliceStart(value, start) {
50
+ let at = Math.max(0, Math.min(value.length, start));
51
+ if (at < value.length && /[\uDC00-\uDFFF]/.test(value[at] ?? ""))
52
+ at++;
53
+ return value.slice(at);
54
+ }
55
+ function clip(value, max, label) {
56
+ const cap = Math.max(0, Math.floor(max));
57
+ if (value.length <= cap)
58
+ return value;
59
+ const marker = `\n…[hara: ${value.length - cap} chars omitted from ${label}]…\n`;
60
+ if (marker.length >= cap)
61
+ return marker.slice(0, cap);
62
+ const room = cap - marker.length;
63
+ const head = Math.floor(room * 0.6);
64
+ const tail = room - head;
65
+ return safeSliceEnd(value, head) + marker + safeSliceStart(value, value.length - tail);
66
+ }
67
+ function boundValue(value, stringCap, depth = 0) {
68
+ if (typeof value === "string")
69
+ return clip(value, stringCap, "historical tool input");
70
+ if (value === null || typeof value !== "object")
71
+ return value;
72
+ if (depth >= 8)
73
+ return "[hara: nested historical tool input omitted]";
74
+ if (Array.isArray(value)) {
75
+ const kept = value.slice(0, 80).map((item) => boundValue(item, stringCap, depth + 1));
76
+ if (value.length > kept.length)
77
+ kept.push(`[hara: ${value.length - kept.length} array items omitted]`);
78
+ return kept;
79
+ }
80
+ const entries = Object.entries(value);
81
+ const out = {};
82
+ for (const [key, item] of entries.slice(0, 80))
83
+ out[key] = boundValue(item, stringCap, depth + 1);
84
+ if (entries.length > 80)
85
+ out._hara_omitted_keys = entries.length - 80;
86
+ return out;
87
+ }
88
+ function boundToolUses(toolUses, stringCap) {
89
+ // Never cap the call ARRAY independently: provider protocols require every following tool_result id to
90
+ // have a matching tool_use. Payloads may shrink, or an entire old exchange may fall out of the suffix,
91
+ // but identities within a retained exchange stay one-to-one.
92
+ return toolUses.map((use) => ({ ...use, input: boundValue(use.input, stringCap) }));
93
+ }
94
+ function addGuard(history) {
95
+ return [{ role: "user", content: GUARD_NOTE }, ...history];
96
+ }
97
+ /** Create a bounded provider snapshot without mutating or deleting the durable transcript. Tool call/result
98
+ * identities and ordering remain intact; only model-visible payload text is clipped. */
99
+ export function prepareHistoryForModel(history, options) {
100
+ const originalChars = historyChars(history);
101
+ const budgetChars = modelHistoryBudget(options);
102
+ const userIndices = history.flatMap((message, index) => message.role === "user" ? [index] : []);
103
+ const latestUser = userIndices.at(-1) ?? -1;
104
+ const recentUsers = new Set(userIndices.slice(-3));
105
+ const imageUsers = new Set(userIndices.slice(-RECENT_IMAGE_TURNS));
106
+ let changed = false;
107
+ let omittedImages = 0;
108
+ let snapshot = history.map((message, index) => {
109
+ if (message.role === "user") {
110
+ const cap = index === latestUser ? MAX_USER_ITEM_CHARS : recentUsers.has(index) ? 40_000 : 20_000;
111
+ let content = clip(message.content, cap, "user message");
112
+ if (content !== message.content)
113
+ changed = true;
114
+ let images = message.images?.map((image) => ({ ...image }));
115
+ if (images?.length && !imageUsers.has(index)) {
116
+ omittedImages += images.length;
117
+ content += `\n\n[hara: ${images.length} older image attachment(s) omitted from this model round; reattach if needed]`;
118
+ images = undefined;
119
+ changed = true;
120
+ }
121
+ return { role: "user", content, ...(images?.length ? { images } : {}) };
122
+ }
123
+ if (message.role === "assistant") {
124
+ const text = clip(message.text, MAX_ASSISTANT_ITEM_CHARS, "assistant message");
125
+ const toolUses = boundToolUses(message.toolUses, MAX_TOOL_INPUT_STRING_CHARS);
126
+ if (text !== message.text || jsonChars(toolUses) !== jsonChars(message.toolUses))
127
+ changed = true;
128
+ return { role: "assistant", text, toolUses };
129
+ }
130
+ const results = message.results.map((result) => {
131
+ const content = clip(result.content, 24_000, `tool result ${result.name}`);
132
+ if (content !== result.content)
133
+ changed = true;
134
+ return { ...result, content };
135
+ });
136
+ return { role: "tool", results };
137
+ });
138
+ const reduce = (toolCap, assistantCap, oldUserCap, recentUserCap, inputCap) => {
139
+ snapshot = snapshot.map((message, index) => {
140
+ if (message.role === "tool") {
141
+ return { role: "tool", results: message.results.map((result) => ({ ...result, content: clip(result.content, toolCap, `tool result ${result.name}`) })) };
142
+ }
143
+ if (message.role === "assistant") {
144
+ return { role: "assistant", text: clip(message.text, assistantCap, "assistant message"), toolUses: boundToolUses(message.toolUses, inputCap) };
145
+ }
146
+ const cap = index === latestUser ? Math.max(recentUserCap, 24_000) : recentUsers.has(index) ? recentUserCap : oldUserCap;
147
+ return { ...message, content: clip(message.content, cap, "user message") };
148
+ });
149
+ changed = true;
150
+ };
151
+ if (historyChars(snapshot) + GUARD_NOTE.length > budgetChars)
152
+ reduce(2_000, 4_000, 6_000, 16_000, 2_000);
153
+ if (historyChars(snapshot) + GUARD_NOTE.length > budgetChars)
154
+ reduce(512, 1_500, 1_500, 8_000, 768);
155
+ if (historyChars(snapshot) + GUARD_NOTE.length > budgetChars)
156
+ reduce(128, 512, 512, 4_000, 256);
157
+ if (changed)
158
+ snapshot = addGuard(snapshot);
159
+ // Extremely long threads can exceed the ceiling through message structure alone. Retain the largest suffix
160
+ // beginning at a user boundary. The durable thread remains available for explicit compaction/export.
161
+ if (historyChars(snapshot) > budgetChars) {
162
+ const starts = snapshot.flatMap((message, index) => message.role === "user" ? [index] : []).reverse();
163
+ let suffix = null;
164
+ for (const start of starts) {
165
+ const candidate = snapshot.slice(start);
166
+ if (historyChars(candidate) + GUARD_NOTE.length <= budgetChars)
167
+ suffix = candidate;
168
+ else if (suffix)
169
+ break;
170
+ }
171
+ if (suffix)
172
+ snapshot = addGuard(suffix[0]?.role === "user" && suffix[0].content === GUARD_NOTE ? suffix.slice(1) : suffix);
173
+ else {
174
+ const latest = [...snapshot].reverse().find((message) => message.role === "user");
175
+ snapshot = [{ role: "user", content: `${GUARD_NOTE}\n\n${latest?.role === "user" ? clip(latest.content, Math.max(1_000, budgetChars - GUARD_NOTE.length - 8), "latest user message") : "Continue the active task from durable task state."}` }];
176
+ }
177
+ changed = true;
178
+ }
179
+ return { history: snapshot, changed, originalChars, preparedChars: historyChars(snapshot), budgetChars, omittedImages };
180
+ }
@@ -0,0 +1,37 @@
1
+ const EVOLUTION_READ_TOOLS = new Set([
2
+ "memory_search",
3
+ "memory_get",
4
+ "read_file",
5
+ "grep",
6
+ "glob",
7
+ "ls",
8
+ "codebase_search",
9
+ ]);
10
+ /** Self-evolution is an auditable memory/skill curation pass, never autonomous product mutation. */
11
+ export const EVOLUTION_SYSTEM = "Review this session for durable, reusable learning. This is an AUDITABLE CURATION pass, not permission to rewrite yourself. " +
12
+ "Use memory_write only for evidence-backed facts, decisions, project conventions, or explicit user preferences. Put tentative or one-off observations in target=log; " +
13
+ "promote directly to memory/user only when stable and clearly supported by the conversation or verified workspace state. Include a short source/evidence phrase and avoid duplicates. " +
14
+ "Use skill_create only for a repeatable procedure that was actually exercised or verified; do not turn a single guess into a playbook. " +
15
+ "Never store secrets, credentials, raw private content, large transcripts, or stale task state. Never edit product code, AGENTS.md, permissions, configuration, or system prompts as 'self-evolution'; " +
16
+ "those require a separate normal task and human-reviewed change. If nothing qualifies, write nothing. Reply only DONE with a short count of memories/skills saved.";
17
+ export function evolutionStatus(config) {
18
+ const mode = config.evolve === "off"
19
+ ? "off — no reflection/distillation runs"
20
+ : config.evolve === "light"
21
+ ? "light — memory tools are available; curation runs on /evolve now or manual /compact"
22
+ : "proactive — eligible session exits reflect automatically; /evolve now is also available";
23
+ const capture = config.assetCapture === "off"
24
+ ? "skill capture off"
25
+ : config.assetCapture === "auto"
26
+ ? "memory/skill writes auto-approved during a curation pass"
27
+ : "memory/skill writes require confirmation during a curation pass";
28
+ return `self-evolution: ${mode}\npolicy: evidence-backed memory + verified reusable skills only; never autonomous code/system-prompt changes\ncapture: ${capture}`;
29
+ }
30
+ export function shouldAutoEvolve(mode, historyLength) {
31
+ return mode === "proactive" && historyLength >= 4;
32
+ }
33
+ /** Runtime capability boundary for curation. In particular, todo_write is not "read-only" here: it would
34
+ * mutate the active execution checkpoint. Network tools are also unnecessary for distilling local evidence. */
35
+ export function allowsEvolutionTool(name, assetCapture) {
36
+ return name === "memory_write" || (assetCapture !== "off" && name === "skill_create") || EVOLUTION_READ_TOOLS.has(name);
37
+ }
@@ -1,7 +1,6 @@
1
1
  // App-level failover — what to do when a provider turn ENDS in an error (the SDK already retried transient
2
- // 429/5xx via maxRetries; this handles what's left). Two recoveries: a context-overflow compact + retry,
3
- // and a persistent/overloaded error retry once on a configured FALLBACK model. The decision is pure +
4
- // tested here; runAgent just executes it (and guards each recovery to once, so no retry loops).
2
+ // 429/5xx via maxRetries; this handles what's left). runAgent first retries context overflow once with a
3
+ // tighter bounded snapshot; this module then decides whether a remaining error gets one fallback-model try.
5
4
  /** Classify a provider error from its message (+ HTTP status if known). Message patterns include the
6
5
  * Chinese strings DashScope/GLM/Qwen return, since hara targets those endpoints. */
7
6
  export function classifyError(msg, status) {
@@ -44,7 +43,7 @@ export function errorHint(kind) {
44
43
  case "overloaded":
45
44
  return " — provider overloaded; set `fallbackModel` to auto-switch on errors";
46
45
  case "context_overflow":
47
- return " — context too long; `/compact` (or enable `autoCompact`)";
46
+ return " — context still too long after bounded retry; use `/compact` or `/new`";
48
47
  case "timeout":
49
48
  return " — network timeout; check connectivity";
50
49
  default:
@@ -19,6 +19,7 @@ import { recordTouch } from "./touched.js";
19
19
  import { resolve as resolvePath } from "node:path";
20
20
  import { redactSensitiveText } from "../security/secrets.js";
21
21
  import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
22
+ import { prepareHistoryForModel } from "./context-budget.js";
22
23
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
23
24
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
24
25
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -91,8 +92,11 @@ independent questions (role "explore") — each returns conclusions, not dumps.
91
92
  mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
92
93
  instead of blindly folding everything into the current task; the todo list is your task queue. For a multi-step task, call \`todo_write\` to plan a short checklist and keep it updated as
93
94
  you go (one item in_progress at a time) — skip it for trivial one-step tasks. You have a persistent
94
- memory: use memory_search before answering about prior decisions,
95
- conventions, or the user's preferences, and memory_write to proactively save durable facts you learn.
95
+ memory: use memory_search before answering about prior decisions, conventions, or the user's preferences.
96
+ Only save evidence-backed learning: tentative/one-off observations go to memory_write target=log; stable
97
+ verified project conventions/decisions may go to project memory, and explicit user preferences to user memory.
98
+ Include a short source/evidence phrase, avoid duplicates, and never treat memory as permission to change code,
99
+ configuration, permissions, AGENTS.md, or your system instructions.
96
100
  When a task matches one of the Skills listed below, call the \`skill\` tool to load its full instructions
97
101
  before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
98
102
  convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff).
@@ -254,6 +258,9 @@ async function runAgentInner(history, opts, life) {
254
258
  const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
255
259
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
256
260
  let triedFallback = false;
261
+ let contextOverflowRetried = false;
262
+ let contextBudgetScale = 1;
263
+ let contextGuardNotified = false;
257
264
  let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
258
265
  const interruptedOutcome = () => {
259
266
  const msg = "(interrupted)";
@@ -348,6 +355,21 @@ async function runAgentInner(history, opts, life) {
348
355
  ? [...baseSpecs, ...opts.extraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
349
356
  : baseSpecs;
350
357
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
358
+ const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext);
359
+ const prepared = prepareHistoryForModel(history, {
360
+ model: activeProvider.model,
361
+ system,
362
+ tools: specs,
363
+ budgetScale: contextBudgetScale,
364
+ });
365
+ if (prepared.changed && !contextGuardNotified && !opts.quiet) {
366
+ contextGuardNotified = true;
367
+ const note = `✻ context guard bounded this model request (${Math.round(prepared.originalChars / 1000)}k → ${Math.round(prepared.preparedChars / 1000)}k chars); durable history is unchanged`;
368
+ if (sink)
369
+ sink.notice(note);
370
+ else
371
+ out(c.dim(`${note}\n`));
372
+ }
351
373
  const tty = stdout.isTTY && !opts.quiet && !sink;
352
374
  const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
353
375
  // Reasoning rendering in plain-terminal mode: we put reasoning on its OWN dim lines (prefixed
@@ -428,8 +450,8 @@ async function runAgentInner(history, opts, life) {
428
450
  return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
429
451
  }
430
452
  return activeProvider.turn({
431
- system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext),
432
- history,
453
+ system,
454
+ history: prepared.history,
433
455
  tools: specs,
434
456
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
435
457
  // reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
@@ -535,6 +557,19 @@ async function runAgentInner(history, opts, life) {
535
557
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
536
558
  if (r.stop === "error") {
537
559
  const kind = classifyError(r.errorMsg ?? "");
560
+ if (kind === "context_overflow" && !contextOverflowRetried) {
561
+ contextOverflowRetried = true;
562
+ contextBudgetScale = 0.5;
563
+ history.pop(); // drop the errored (partial/empty) assistant turn before a tighter normalized retry
564
+ if (!opts.quiet) {
565
+ const note = "✻ context overflow → retrying once with a tighter bounded history snapshot…";
566
+ if (sink)
567
+ sink.notice(note);
568
+ else
569
+ out(c.dim(`${note}\n`));
570
+ }
571
+ continue;
572
+ }
538
573
  if (failoverAction(kind, { hasFallback: !!opts.fallback?.provider, triedFallback }) === "fallback") {
539
574
  triedFallback = true;
540
575
  history.pop(); // drop the errored (partial/empty) assistant turn before retrying