@nanhara/hara 0.123.0 → 0.124.0
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 +42 -0
- package/README.md +12 -9
- package/dist/agent/compact.js +98 -18
- package/dist/agent/context-budget.js +180 -0
- package/dist/agent/evolution.js +37 -0
- package/dist/agent/failover.js +3 -4
- package/dist/agent/loop.js +39 -4
- package/dist/config.js +27 -90
- package/dist/desk.js +8 -14
- package/dist/fs-read.js +26 -7
- package/dist/gateway/weixin.js +44 -42
- package/dist/index.js +240 -54
- package/dist/org-fleet/enroll.js +17 -22
- package/dist/plugins/plugins.js +7 -9
- package/dist/profile/profile.js +29 -56
- package/dist/providers/qwen-oauth.js +6 -19
- package/dist/security/private-state.js +285 -5
- package/dist/serve/server.js +59 -35
- package/dist/serve/sessions.js +12 -4
- package/dist/session/store.js +4 -0
- package/dist/session/task.js +70 -8
- package/dist/tools/memory.js +18 -3
- package/dist/tui/App.js +43 -17
- package/dist/tui/bracketed-paste.js +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ 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.0 — 2026-07-16 — task-safe interaction, bounded context, and private-state hardening
|
|
9
|
+
|
|
10
|
+
- **Idle conversation no longer hijacks an unfinished task.** Ordinary input starts a new execution while
|
|
11
|
+
keeping the conversation thread; explicit `继续` / `continue` / `resume` / `go on` resumes the paused task,
|
|
12
|
+
and `/new` creates a visible task boundary. While a turn is live, Enter still steers it; `/next <message>`
|
|
13
|
+
queues a separate next task.
|
|
14
|
+
- **Accepted Desktop steering is crash-safe and exactly-once.** `session.steer` persists a bounded pending
|
|
15
|
+
inbox entry before ACK, writes the projected transcript before delivery, then marks it consumed. Legacy
|
|
16
|
+
audit entries are never replayed, a full pending inbox applies backpressure, and secrets remain redacted.
|
|
17
|
+
- **Every model request has a total context boundary.** Oversized historical user/assistant/tool payloads,
|
|
18
|
+
tool-call arguments, and old images are normalized in a provider-only snapshot without rewriting durable
|
|
19
|
+
history. A context-overflow response gets one tighter same-model retry before configured fallback.
|
|
20
|
+
- **Compaction is now a bounded execution checkpoint.** The summarizer receives a guarded source snapshot,
|
|
21
|
+
emits structured goals/constraints/decisions/verification/files/errors/checkpoint/blockers/next action,
|
|
22
|
+
and keeps the last three user-turn groups as an anti-drift anchor instead of copying every user message
|
|
23
|
+
into the summary. CLI and Serve use the same contract and restore current touched files.
|
|
24
|
+
- **Self-evolution is explicit and auditable.** `/evolve status|now` exposes the mode and manual curation;
|
|
25
|
+
classic and TUI exits share proactive reflection. Candidate observations go to logs, only stable evidence-
|
|
26
|
+
backed facts/preferences enter memory, verified repeatable procedures may become skills, and autonomous
|
|
27
|
+
code/config/permission/system-prompt mutation is explicitly outside the feature.
|
|
28
|
+
- **Credential files share one no-alias storage boundary.** Desk registration, identity profiles, Qwen OAuth,
|
|
29
|
+
Weixin credentials/cursors/context tokens, legacy org enrollment, and global config mutations now use
|
|
30
|
+
symlink-free 0700 directories, bounded no-follow reads, hard-link rejection, 0600 fsynced staging, and
|
|
31
|
+
identity-checked compare-and-swap publication. Preseeded aliases fail closed without changing external file
|
|
32
|
+
bytes or permissions; legacy `org.json` archival/removal is bound to the exact verified inode.
|
|
33
|
+
- **Text tools no longer rewrite undecodable bytes.** Existing-file preflight for `edit_file`, `write_file`,
|
|
34
|
+
and single/multi-file `apply_patch` uses fatal UTF-8 decoding and keeps the NUL/binary refusal. Invalid input
|
|
35
|
+
fails before the first commit with its original bytes intact, while a valid UTF-8 BOM is preserved across an
|
|
36
|
+
edit instead of being silently stripped.
|
|
37
|
+
|
|
38
|
+
## 0.123.1 — 2026-07-15 — restore default TUI keyboard input
|
|
39
|
+
|
|
40
|
+
- **The bracketed-paste proxy now resumes the real terminal stream.** The main TUI closes its temporary
|
|
41
|
+
readline owner before Ink mounts, which leaves `process.stdin` explicitly paused. 0.123.0 forwarded raw-mode
|
|
42
|
+
calls through the new proxy but did not resume that wrapped stream, so the interface rendered while ordinary
|
|
43
|
+
keystrokes never reached the input box. The proxy now mirrors Ink's read/raw lifecycle by resuming on demand
|
|
44
|
+
and pausing again during cleanup.
|
|
45
|
+
- **The regression starts from production's actual precondition.** A paused-stdin test verifies ordinary input,
|
|
46
|
+
raw-mode enable/disable, and cleanup instead of relying only on a naturally flowing test stream. Real PTY
|
|
47
|
+
smoke covers ordinary typing, multiline bracketed paste without auto-submit, and terminal-mode restoration.
|
|
48
|
+
Users on 0.123.0 should upgrade; `HARA_TUI=0 hara` remains a temporary classic-input workaround.
|
|
49
|
+
|
|
8
50
|
## 0.123.0 — 2026-07-15 — task-aware interaction and input/security hardening
|
|
9
51
|
|
|
10
52
|
- **Conversation and execution are separate state.** Sessions now persist a bounded, redacted task record
|
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
|
|
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
|
|
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
|
|
261
|
-
paused rather than being inferred from chat text
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
- **
|
|
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.
|
package/dist/agent/compact.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export const
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
package/dist/agent/failover.js
CHANGED
|
@@ -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).
|
|
3
|
-
//
|
|
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`
|
|
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:
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|