@nanhara/hara 0.70.0 → 0.89.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 +321 -0
- package/README.md +4 -2
- package/dist/agent/compact.js +10 -0
- package/dist/agent/context-report.js +33 -0
- package/dist/agent/failover.js +53 -0
- package/dist/agent/loop.js +75 -5
- package/dist/agent/rewind.js +20 -0
- package/dist/agent/route.js +47 -0
- package/dist/checkpoints.js +91 -0
- package/dist/config.js +10 -2
- package/dist/context/subdir-hints.js +76 -0
- package/dist/cron/runner.js +7 -4
- package/dist/exec/jobs.js +82 -0
- package/dist/gateway/dingtalk.js +209 -0
- package/dist/gateway/discord.js +164 -0
- package/dist/gateway/feishu.js +144 -0
- package/dist/gateway/matrix.js +188 -0
- package/dist/gateway/mattermost.js +206 -0
- package/dist/gateway/serve.js +282 -0
- package/dist/gateway/sessions.js +89 -0
- package/dist/gateway/slack.js +190 -0
- package/dist/gateway/telegram.js +115 -0
- package/dist/gateway/tts.js +100 -0
- package/dist/gateway/weixin.js +590 -0
- package/dist/index.js +254 -50
- package/dist/sandbox.js +15 -12
- package/dist/security/external-content.js +57 -0
- package/dist/security/permissions.js +211 -0
- package/dist/tools/builtin.js +49 -2
- package/dist/tools/send.js +35 -0
- package/dist/tools/web.js +4 -3
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,327 @@ 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.89.0 — unreleased (gateway: Slack · Mattermost · Matrix · DingTalk + docs)
|
|
9
|
+
|
|
10
|
+
- **Four more platforms**, all zero-new-dep (native WebSocket / `fetch`), same `ChatAdapter` seam:
|
|
11
|
+
- **Slack** — Socket Mode (connects out, no public URL). `HARA_SLACK_APP_TOKEN` (xapp-) + `HARA_SLACK_BOT_TOKEN`
|
|
12
|
+
(xoxb-). Inbound text + image files (downloaded via the bot token); outbound text + file upload.
|
|
13
|
+
- **Mattermost** — v4 WebSocket + REST. `HARA_MATTERMOST_URL` + `HARA_MATTERMOST_TOKEN` (bot/PAT). Two-way images.
|
|
14
|
+
- **Matrix** — `/sync` long-poll. `HARA_MATRIX_HOMESERVER` + `HARA_MATRIX_TOKEN` + `HARA_MATRIX_USER_ID`.
|
|
15
|
+
Two-way images; **unencrypted rooms only in v1** (no E2EE).
|
|
16
|
+
- **DingTalk (钉钉)** — Stream Mode (connects out). `HARA_DINGTALK_CLIENT_ID` + `HARA_DINGTALK_CLIENT_SECRET`.
|
|
17
|
+
Text in/out via the per-message `sessionWebhook`; **v1: no file send, inbound images arrive as `[图片]`**.
|
|
18
|
+
- Each platform's wire parser (`parseSlackEvent` / `parseMattermostPost` / `parseMatrixEvent` / `parseMxc` /
|
|
19
|
+
`parseDingtalkMessage`) is pure + unit-tested.
|
|
20
|
+
- **Docs** — new **[docs/gateway.md](docs/gateway.md)** documents all 8 platforms (Telegram · WeChat · Discord ·
|
|
21
|
+
Feishu/Lark · Slack · Mattermost · Matrix · DingTalk): a capabilities table, common config
|
|
22
|
+
(`HARA_GATEWAY_ALLOWED`, `--cwd`, slash commands, two-way images), and per-platform setup. Linked from README.
|
|
23
|
+
- Ported by studying the openclaw + hermes adapter implementations; live-tested per platform as tokens become
|
|
24
|
+
available.
|
|
25
|
+
|
|
26
|
+
## 0.88.0 — unreleased (gateway: Feishu/Lark adapter)
|
|
27
|
+
|
|
28
|
+
- **Feishu/Lark** — `hara gateway --platform feishu` via the official `@larksuiteoapi/node-sdk` (the one new
|
|
29
|
+
dependency): a **WSClient long-connection** for inbound (no public webhook needed — fits the local daemon) and
|
|
30
|
+
the REST Client for outbound. Inbound text / rich-text post / image / file / audio (media downloaded to
|
|
31
|
+
`~/.hara/feishu/media` → the agent SEES images); outbound text + `sendFile` (image upload → image message,
|
|
32
|
+
else file upload → file message) so `send_file` / `/send` work. Creds via `HARA_FEISHU_APP_ID` /
|
|
33
|
+
`HARA_FEISHU_APP_SECRET` (+ `HARA_FEISHU_DOMAIN=lark` for larksuite.com), users via `HARA_GATEWAY_ALLOWED`
|
|
34
|
+
(open_id). v1 = p2p DMs; group support is a fast-follow. `parseFeishuContent` / `flattenPost` are pure + tested.
|
|
35
|
+
|
|
36
|
+
## 0.87.0 — unreleased (gateway: Discord adapter)
|
|
37
|
+
|
|
38
|
+
- **Discord** — `hara gateway --platform discord` connects to the Discord gateway over Node's native global
|
|
39
|
+
WebSocket (zero new dep on Node ≥ 22): HELLO→heartbeat→IDENTIFY, dispatches MESSAGE_CREATE, auto-reconnects.
|
|
40
|
+
Inbound text + image attachments (downloaded to `~/.hara/discord/media` → the agent SEES them); outbound text
|
|
41
|
+
(2000-char chunks) and `sendFile` (multipart) so `send_file` / `/send` work. Token via `HARA_DISCORD_TOKEN`,
|
|
42
|
+
users via `HARA_GATEWAY_ALLOWED` (Discord user ids). Needs the bot's privileged Message Content Intent.
|
|
43
|
+
Same `ChatAdapter` seam as Telegram/WeChat — all cross-platform gateway logic worked unchanged.
|
|
44
|
+
|
|
45
|
+
## 0.86.1 — unreleased (gateway: Telegram reaches image parity with WeChat)
|
|
46
|
+
|
|
47
|
+
- **Telegram two-way images** — `parseTelegramUpdate` now accepts photo messages (caption or a `[图片]` marker)
|
|
48
|
+
and the receive loop downloads the largest photo to `~/.hara/telegram/media` → `InboundMsg.images`, so the
|
|
49
|
+
agent sees it just like on WeChat. Added `sendFile` (sendPhoto for images, sendDocument otherwise) so `send_file`
|
|
50
|
+
and `/send` work on Telegram too. All the cross-platform gateway plumbing (send_file tool, in-chat system
|
|
51
|
+
context, stuck-guard, `-p` image attach/describe) was already platform-agnostic — only the adapter changed.
|
|
52
|
+
|
|
53
|
+
## 0.86.0 — unreleased (gateway: the agent SEES inbound images)
|
|
54
|
+
|
|
55
|
+
- **Inbound image understanding** — a photo you send in chat now reaches the model as a real image, not just a
|
|
56
|
+
`[图片: /path]` text breadcrumb. The WeChat adapter routes downloaded images into `InboundMsg.images`; the
|
|
57
|
+
gateway forwards their paths to the headless run (`HARA_GATEWAY_IMAGES`); the `-p` handler attaches them inline
|
|
58
|
+
for a vision-capable main model, or describes them via the configured `visionModel` sidecar and folds the
|
|
59
|
+
description into the message for a text-only model (e.g. glm-5 main + qwen3.7-plus vision). Together with 0.85's
|
|
60
|
+
`send_file`, the chat is now two-way for images. Verified live: glm-5 correctly described a sent picture via the
|
|
61
|
+
qwen3.7-plus sidecar.
|
|
62
|
+
|
|
63
|
+
## 0.85.0 — unreleased (gateway: agent sends files in conversation + stuck-guard)
|
|
64
|
+
|
|
65
|
+
- **`send_file` tool** — the agent can now deliver a file/image to the chat *conversationally* ("生成 X 发我"),
|
|
66
|
+
not just via the manual `/send` command. Self-gates on `HARA_GATEWAY`, so it appears only inside `hara gateway`.
|
|
67
|
+
It queues the path to a per-message outbox file; the daemon drains it after the headless run and delivers each
|
|
68
|
+
file via the platform adapter (`sendMediaFile` — images inline, others as attachments). This closes the gap
|
|
69
|
+
where hara, asked to "send an image", would generate it fine but then try to UI-automate the desktop WeChat
|
|
70
|
+
client with the `computer` tool (wrong surface, and blind without a vision model) and silently deliver nothing.
|
|
71
|
+
- **In-chat system context** — when running under the gateway, the agent is told it's in a `${platform}` chat:
|
|
72
|
+
deliver files via `send_file` (the only channel that reaches the peer), do **not** drive the desktop client /
|
|
73
|
+
`computer` tool, and never claim a file was sent unless `send_file` succeeded.
|
|
74
|
+
- **Stuck-guard (gateway only)** — once per run, if the agent keeps repeating one non-read tool (≥5×) or acts
|
|
75
|
+
blind (≥2 screenshots it can't read), a one-shot self-check nudge is injected so it steps back and picks a
|
|
76
|
+
working path instead of grinding forever (no human there to hit Esc). Off outside the gateway.
|
|
77
|
+
|
|
78
|
+
## 0.84.0 — unreleased (gateway: file + image send/receive)
|
|
79
|
+
|
|
80
|
+
- **Receive** — a file / image / (untranscribed) voice you send is now downloaded + AES-decrypted to a local
|
|
81
|
+
file under `~/.hara/weixin/media/`, and hara is handed a `[图片|文件 name|语音: /path]` reference so it can
|
|
82
|
+
read/process it (images → a vision-capable model). Ported iLink's inbound media path (CDN download →
|
|
83
|
+
AES-128-ECB decrypt) including the image `aeskey` hex-hack, the `encrypt_query_param` vs `encrypted_query_param`
|
|
84
|
+
naming, the conditional PKCS7 strip, the CDN-host SSRF allowlist, and a 128 MiB cap. Media-only messages
|
|
85
|
+
(no text) are now processed too.
|
|
86
|
+
- **Send** — `sendMediaFile` sends any local file: images go **inline** (`image_item`), everything else
|
|
87
|
+
(zip / pdf / doc / audio / …) as a **file attachment** (`file_item`) carrying the filename. New `/send <path>`
|
|
88
|
+
command (absolute / `~` / relative to the chat's dir). Voice replies + `/say` now ride the same generic path.
|
|
89
|
+
- `ChatAdapter.sendAudio?` → **`sendFile?`** (generic seam). Live-validated end-to-end: ZIP attachment + inline
|
|
90
|
+
image delivered. 265 tests.
|
|
91
|
+
|
|
92
|
+
## 0.83.0 — unreleased (gateway: voice replies — pluggable TTS, /voice + /say)
|
|
93
|
+
|
|
94
|
+
- The gateway can now **reply with voice** — a WeChat audio-file attachment (iLink's native voice bubble is
|
|
95
|
+
unreliable). `/voice` toggles spoken replies for a chat (each reply is also sent as audio); `/say <text>`
|
|
96
|
+
speaks one message. Ported iLink's media-upload path (getuploadurl → AES-128-ECB → CDN → `file_item`) into
|
|
97
|
+
`weixin.ts` byte-exact (incl. the dual AES-key encoding: hex in getuploadurl, base64-of-hex in sendmessage)
|
|
98
|
+
+ live-validated end-to-end.
|
|
99
|
+
- **Pluggable TTS** (`src/gateway/tts.ts`) — config-driven via env, nothing vendor-hardcoded, mirrors the video
|
|
100
|
+
project's provider design; both API and local:
|
|
101
|
+
- `say` (default) — local macOS, ~0.5s, Chinese voices (Tingting…), zero config → m4a.
|
|
102
|
+
- `openai` — any OpenAI-compatible `/audio/speech` endpoint (point `HARA_TTS_BASE_URL` at Aliyun DashScope or
|
|
103
|
+
a local TTS server); reuses the existing `openai` dep, no new dependency.
|
|
104
|
+
- `cmd` — a configurable local command (point `HARA_TTS_CMD` at VoxCPM or any local TTS; text on stdin).
|
|
105
|
+
- Select via `HARA_TTS_PROVIDER` (+ `HARA_TTS_VOICE`/`MODEL`/`BASE_URL`/`API_KEY`/`CMD`); a failed API
|
|
106
|
+
provider falls back to local `say`. New optional `ChatAdapter.sendAudio?` seam (weixin implements it).
|
|
107
|
+
|
|
108
|
+
## 0.82.2 — unreleased (gateway: fix the voice tag that made hara disclaim)
|
|
109
|
+
|
|
110
|
+
- 0.82.1's bare `[voice message]` prefix backfired — hara read it as raw audio to process and replied "I can't
|
|
111
|
+
handle voice," even though the message was already transcribed text (verified: the transcription reaches hara
|
|
112
|
+
correctly). Replaced with an explicit note ("…already transcribed to text below — just reply to it normally,
|
|
113
|
+
you don't have or need the audio") so hara answers the content instead of disclaiming.
|
|
114
|
+
|
|
115
|
+
## 0.82.1 — unreleased (gateway: tag transcribed WeChat voice messages)
|
|
116
|
+
|
|
117
|
+
- WeChat **voice input already works** — iLink transcribes voice server-side (`voice_item.text`) and the gateway
|
|
118
|
+
reads it. But the text reached hara unlabeled, so when the spoken words referenced "this voice," hara replied
|
|
119
|
+
as if it only got text. Now a transcribed voice message is tagged `[voice message] <text>`, so hara knows the
|
|
120
|
+
input came from voice. (Sending voice back is deferred — iLink's native voice bubble is unreliable per the
|
|
121
|
+
Hermes reference; only audio-file attachments are possible, and that needs the media-upload + TTS path.)
|
|
122
|
+
|
|
123
|
+
## 0.82.0 — unreleased (gateway: roam projects + threads from chat — /cd, /pwd, project-scoped /sessions)
|
|
124
|
+
|
|
125
|
+
- A chat can now **switch working directory at runtime**, and its session follows: `/cd <dir>` (absolute, `~`,
|
|
126
|
+
or relative to the current dir) moves the chat into that project and opens that project's own thread; `/pwd`
|
|
127
|
+
shows the current dir + thread; `/sessions` lists the **current dir's** threads; `/new` forks the current
|
|
128
|
+
dir's thread; `/resume <id>` jumps to a thread and adopts its dir so it runs in the right place. Each
|
|
129
|
+
(chat, dir) pair gets its own stable, resumable session id (`<platform>-<chatId>-<cwdTag>[-fork]`) — so one
|
|
130
|
+
gateway roams across projects while keeping per-project history, and switching back resumes. Foundation for
|
|
131
|
+
a future desktop client.
|
|
132
|
+
- The chat-session store (`~/.hara/gateway/chats.json`) is now cwd-aware (`chatContext`/`chatCd`); old entries
|
|
133
|
+
migrate in place (keep their existing thread). Tested.
|
|
134
|
+
|
|
135
|
+
## 0.81.6 — unreleased (headless `-p --resume`: auto-compact long sessions)
|
|
136
|
+
|
|
137
|
+
- The `hara -p … --resume <id>` / `--continue` path (used by the chat gateway and cron) now runs
|
|
138
|
+
`maybeAutoCompact` before saving, so a long chat/cron thread **auto-compacts** (summarizes old turns)
|
|
139
|
+
instead of growing until it overflows the model's context window. Silent in headless mode (no notify) so
|
|
140
|
+
nothing leaks into a captured reply. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Previously
|
|
141
|
+
auto-compaction was wired only into the interactive/TUI loops, so gateway/cron sessions could overflow.
|
|
142
|
+
|
|
143
|
+
## 0.81.5 — unreleased (gateway: default workspace = ~/.hara/workspace — dir-free, Hermes-style)
|
|
144
|
+
|
|
145
|
+
- `hara gateway` (no `--cwd`) now operates in a dedicated **`~/.hara/workspace`** (created + seeded on first
|
|
146
|
+
run) instead of the launch directory — so the chat bot is dir-free + safe-by-default (like Hermes' own
|
|
147
|
+
`~/.hermes`), never landing full-auto on whatever repo you happened to launch from. `--cwd <dir>` still
|
|
148
|
+
targets a real project.
|
|
149
|
+
- This only sets where *files* are created. hara's **global memory (`~/.hara/memory`) and roles
|
|
150
|
+
(`~/.hara/roles` + B-end `~/.hara/org-roles`) are cwd-independent and always loaded**, so a chat session
|
|
151
|
+
shares the same global brain as the terminal CLI regardless of workspace. `~/.hara/` is hara's home:
|
|
152
|
+
memory · roles · sessions · skills · checkpoints · config · per-platform gateway state.
|
|
153
|
+
|
|
154
|
+
## 0.81.4 — unreleased (gateway: clean chat replies — strip MCP logs + token footer)
|
|
155
|
+
|
|
156
|
+
- The chat gateway scraped the `hara -p` subprocess output and sent it verbatim, so WeChat/Telegram replies
|
|
157
|
+
were wrapped in CLI chrome — `mcp: … → N tool(s)`, `mcp: … failed …`, and the `model · ↑N ↓N tok` footer.
|
|
158
|
+
New `cleanReply()` strips those, leaving just the assistant's answer. (Tool/diff streaming for multi-step
|
|
159
|
+
tasks is untouched; this only removes the always-present MCP-startup + token-footer noise.) Tested.
|
|
160
|
+
|
|
161
|
+
## 0.81.3 — unreleased (fix: gateway/cron subprocess spawn when hara runs via the `hara` bin symlink)
|
|
162
|
+
|
|
163
|
+
- `selfArgv()` (used by the chat gateway and the cron tick to spawn a fresh `hara` per task) **dropped the
|
|
164
|
+
script path** when hara was invoked via the installed `hara` bin symlink: that path has no `.js` extension,
|
|
165
|
+
so the old extension-based heuristic returned just `[node]`, making the spawn `node -p <text> --approval …`
|
|
166
|
+
— which node rejected with `bad option: --approval` (the gateway replied with that error instead of running).
|
|
167
|
+
Now keyed on whether `execPath` is node (the real compiled-binary discriminator): it re-invokes the entry
|
|
168
|
+
(`dist/index.js` **or** the bin symlink) under node, and only re-invokes `execPath` directly for a true
|
|
169
|
+
single-binary build. Test covers all three cases.
|
|
170
|
+
|
|
171
|
+
## 0.81.2 — unreleased (gateway: `--cwd` flag to point at a workspace without `cd`)
|
|
172
|
+
|
|
173
|
+
- `hara gateway [--platform …] --cwd <dir>` sets the directory hara operates in for each incoming message,
|
|
174
|
+
so you can launch the daemon against a chosen workspace without `cd`-ing first (resolved to an absolute
|
|
175
|
+
path). Defaults to the current dir as before. Recommended pattern: a dedicated safe scratch dir (e.g.
|
|
176
|
+
`~/work/projects/tools/hara`) rather than a sensitive repo, since each message runs `--approval full-auto`.
|
|
177
|
+
|
|
178
|
+
## 0.81.1 — unreleased (WeChat gateway: auto-allowlist the bot owner)
|
|
179
|
+
|
|
180
|
+
- `hara gateway --platform weixin` now **auto-allows the bot owner** (the iLink `user_id` from login — i.e.
|
|
181
|
+
whoever scanned the QR), since on a personal-WeChat bot that id equals the `from_user_id` on your own
|
|
182
|
+
messages. Removes the "run once with an empty allowlist to discover your wxid, then re-run" dance — just
|
|
183
|
+
log in and run the daemon. Additional ids can still be added via `HARA_GATEWAY_ALLOWED`. (Validated the full
|
|
184
|
+
receive→reply round-trip against the live iLink server.)
|
|
185
|
+
|
|
186
|
+
## 0.81.0 — unreleased (WeChat (iLink) gateway adapter — drive your local hara from personal WeChat)
|
|
187
|
+
|
|
188
|
+
- **`hara gateway --platform weixin`** adds **WeChat (personal)** as a second chat channel, via Tencent's
|
|
189
|
+
official iLink bot API (`ilinkai.weixin.qq.com`, no ban risk) — the same `ChatAdapter` seam as Telegram,
|
|
190
|
+
so per-chat resumable sessions / allowlist / `/new` `/sessions` `/resume` all carry over. Text DMs (v1):
|
|
191
|
+
the bot reads & edits files and runs bash in the gateway's cwd, and replies in WeChat.
|
|
192
|
+
- **`hara gateway --platform weixin --login`** — interactive QR login (scan with WeChat); saves
|
|
193
|
+
`{account_id, token, base_url}` to `~/.hara/weixin/creds.json`. The per-peer `context_token` and the
|
|
194
|
+
long-poll cursor are persisted, so replies route correctly and a restart resumes mid-stream. Handles
|
|
195
|
+
iLink's `-14` / stale-`-2` session-expiry (tokenless retry on send; re-login prompt on poll). Built-in
|
|
196
|
+
fetch; the login QR renders via the **optional** `qrcode-terminal` dep (falls back to printing the URL).
|
|
197
|
+
No crypto needed on the text path.
|
|
198
|
+
- The allowlist now logs the sender id of unauthorized messages, so you can discover your WeChat id to add
|
|
199
|
+
to `HARA_GATEWAY_ALLOWED`. 256 tests.
|
|
200
|
+
|
|
201
|
+
## 0.80.0 — unreleased (chat gateway — drive your local hara from Telegram; + headless session continuity)
|
|
202
|
+
|
|
203
|
+
- **`hara gateway`** (opt-in daemon, hara's first long-running process) lets you drive your **local** hara from
|
|
204
|
+
a chat app — **Telegram** first ("message your bot → hara reads/edits files, runs bash, replies"). Each chat
|
|
205
|
+
is a **continuous, resumable session**: `/new` forks a fresh thread · `/sessions` lists · `/resume <id>`
|
|
206
|
+
jumps to one — backed by the stored sessions. Access is **allowlist-gated** (`HARA_GATEWAY_ALLOWED` user ids;
|
|
207
|
+
empty = nobody, never wide-open); token from `HARA_TELEGRAM_TOKEN`. Generic `ChatAdapter` shape → WeChat-iLink
|
|
208
|
+
/ Feishu are same-interface fast-follows. **Zero new dep** (built-in `fetch` long-poll). `src/gateway/` + tests.
|
|
209
|
+
- **`hara -p "<task>" --resume <id>` / `--continue`** now does **headless session continuity** — loads the
|
|
210
|
+
session, appends the prompt, runs, saves it back (a `--resume <id>` with no match is created with that id);
|
|
211
|
+
plain `hara -p` stays stateless. Useful for cron, scripts, and the gateway's per-chat threads.
|
|
212
|
+
- This is the chat layer of the multi-terminal plan; the ACP server (for a custom App / editors) is deferred
|
|
213
|
+
until that App exists. 249 tests.
|
|
214
|
+
|
|
215
|
+
## 0.79.0 — unreleased (app-level failover — retry an errored turn on a fallback model)
|
|
216
|
+
|
|
217
|
+
- **`fallbackModel`** (opt-in): when a turn ends in a *recoverable* provider error — overload (529/503),
|
|
218
|
+
rate-limit (429), timeout, transient 5xx, or context-overflow — hara retries it **once on the fallback
|
|
219
|
+
model** instead of dying (a different model may not be overloaded / may have a larger window). `auth` errors
|
|
220
|
+
and user interrupts are never auto-retried. The SDK's transient `maxRetries:4` still handles the first line;
|
|
221
|
+
this is the app layer for what's left. `fallbackBaseURL`/`fallbackApiKey` default to the primary's.
|
|
222
|
+
- Errors now also carry a short **actionable hint** by kind (auth → check key · overloaded → set fallbackModel
|
|
223
|
+
· context-overflow → /compact). Classification handles DashScope/GLM/Qwen **Chinese** error strings too. New
|
|
224
|
+
`src/agent/failover.ts` (pure classify + decide, fully tested); runAgent just executes the decision, guarded
|
|
225
|
+
to one retry. Tests (244 total). Completes the Sprint-2 spine (⑤ rewind + ⑥ failover).
|
|
226
|
+
|
|
227
|
+
## 0.78.0 — unreleased (file-state checkpoints — shadow-git "undo the agent's edits")
|
|
228
|
+
|
|
229
|
+
- **Durable file checkpoints** complete the rewind story (beyond the edit-only in-memory undo, which missed
|
|
230
|
+
`bash`-made changes). Before each turn, hara snapshots the whole working tree into a **shadow git repo** kept
|
|
231
|
+
OUTSIDE the project (`~/.hara/checkpoints/<hash>`, `GIT_DIR` there + `GIT_WORK_TREE` = project root) — so it
|
|
232
|
+
captures everything (incl. `bash`), **never touches your real `.git`/index**, and the model never sees it.
|
|
233
|
+
**`/checkpoint`** lists them; **`/checkpoint restore <n>`** reverts files to one.
|
|
234
|
+
- **Safe by construction**: restore snapshots the current state first (so it's undoable) and only reverts
|
|
235
|
+
changed/deleted files — it **never deletes files created since** the checkpoint. Heavy/derived dirs
|
|
236
|
+
(`node_modules`, `.git`, `dist`, …) are excluded; only the first snapshot is a full scan (git's index makes
|
|
237
|
+
the rest incremental). Default on; opt out with `fileCheckpoints:false` / `HARA_CHECKPOINTS=0`.
|
|
238
|
+
`src/checkpoints.ts` + tests (241 total).
|
|
239
|
+
|
|
240
|
+
## 0.77.0 — unreleased (/rewind — fork the conversation back to an earlier turn)
|
|
241
|
+
|
|
242
|
+
- New **`/rewind`** — `/rewind` lists recent user turns; `/rewind <n>` forks the conversation back to before
|
|
243
|
+
one, so when the agent goes down a wrong path you can snip back to a good turn and re-steer, instead of
|
|
244
|
+
`/clear` (lose everything) or living with a poisoned context (codex's backtrack). **Conversation only —
|
|
245
|
+
file edits are NOT reverted** (durable file-state checkpoints via shadow-git are the planned heavier
|
|
246
|
+
follow-up). Both UIs; pure in-memory + session store. `src/agent/rewind.ts` + tests (239 total).
|
|
247
|
+
|
|
248
|
+
## 0.76.0 — unreleased (/context — see what's filling the context window)
|
|
249
|
+
|
|
250
|
+
- New **`/context`** command: a token-spend breakdown of the conversation — which tool's output, assistant
|
|
251
|
+
text, and your messages are using the window (biggest first, with the share of the model's window) — so on a
|
|
252
|
+
long session you can see *why* you're near the limit, not just the `ctx%` number. Pairs with auto-compaction.
|
|
253
|
+
chars/4 estimate, zero-dep, both UIs. `src/agent/context-report.ts` + tests (237 total).
|
|
254
|
+
|
|
255
|
+
## 0.75.0 — unreleased (lazy subdirectory AGENTS.md / CLAUDE.md — monorepo-local conventions reach the model)
|
|
256
|
+
|
|
257
|
+
- When a tool touches a directory not seen yet this session, hara loads that directory's **`AGENTS.md` /
|
|
258
|
+
`CLAUDE.md`** (the local conventions for that package) and appends it to the tool result — so in a monorepo,
|
|
259
|
+
`packages/api/AGENTS.md` or `growth/CLAUDE.md` reaches the model exactly when work moves there, not just the
|
|
260
|
+
root doc loaded at startup. Each directory loads once per session; only dirs **under cwd** (startup already
|
|
261
|
+
covers cwd→root); paths are taken from a tool's `path` or path-like tokens in a `bash` command. Zero-dep,
|
|
262
|
+
additive (never removes context). `src/context/subdir-hints.ts` + tests (235 total).
|
|
263
|
+
|
|
264
|
+
## 0.74.1 — unreleased (bash output keeps head + tail, not just head)
|
|
265
|
+
|
|
266
|
+
- Long command output (build / test logs) is now truncated **keeping both the head and the tail** instead of
|
|
267
|
+
only the first 100k chars — so the model still sees the **end**, where the error/result usually is (plain
|
|
268
|
+
head-truncation cut exactly the part that matters). `read_file` truncation is unchanged. New `capHeadTail`
|
|
269
|
+
used on `bash` success + failure output; + test (233 total).
|
|
270
|
+
|
|
271
|
+
## 0.74.0 — unreleased (auto-compaction — summarize before the context overflows, like Claude Code)
|
|
272
|
+
|
|
273
|
+
- **Auto-compaction**: when a turn fills the model's context past ~85%, hara now **summarizes the conversation
|
|
274
|
+
and continues automatically** (a "✻ Auto-compacting conversation…" notice) instead of only warning — so a
|
|
275
|
+
long session no longer dead-ends at the context limit. Opt out with `autoCompact: false` (or
|
|
276
|
+
`HARA_AUTO_COMPACT=0`); below the threshold the ≥80% warning still shows. Works in the TUI **and** the classic
|
|
277
|
+
REPL.
|
|
278
|
+
- The summarize-and-replace logic is now a single shared `compactConversation` (manual `/compact` and auto both
|
|
279
|
+
use it — no drift); it keeps the working-memory notes that survive the wipe and resets the context gauge off
|
|
280
|
+
the (small) summary. New `src/agent/compact.ts` (`shouldAutoCompact` trigger) + test (232 total).
|
|
281
|
+
- The two distinct controls (unchanged, just clarified): **`/compact`** summarizes → replaces history to free
|
|
282
|
+
tokens while keeping the thread; **`/clear`** (= `/reset`) wipes the conversation for a fresh start.
|
|
283
|
+
|
|
284
|
+
## 0.73.0 — unreleased (Sprint 2: background shell jobs — run dev servers / watchers without blocking)
|
|
285
|
+
|
|
286
|
+
- **`bash {background: true}`** starts a long-lived command (dev server, `tsc --watch`, a long build) as a
|
|
287
|
+
background job and returns a job id immediately — the agent keeps working instead of blocking on a command
|
|
288
|
+
that never exits. New **`job` tool** (`list` / `tail` / `kill`) manages them; output is captured to a capped
|
|
289
|
+
tail buffer. Background jobs reuse `bash`'s exact **sandbox write-confinement** (shared `shellCommand`) and
|
|
290
|
+
pass the same permission gate when started; they're the agent's own children and are **terminated when hara
|
|
291
|
+
exits** (no orphaned dev servers). `src/exec/jobs.ts` + tests (231 total). First slice of the exec-subsystem
|
|
292
|
+
gap from the 4-expert analysis — persistent/interactive PTY + docker/ssh backends come next.
|
|
293
|
+
|
|
294
|
+
## 0.72.0 — unreleased (per-turn model routing: strong model for code, cheap/general for trivial turns)
|
|
295
|
+
|
|
296
|
+
- **Opt-in per-turn model routing** — the answer to "use a coding model for real work, a cheap/general model
|
|
297
|
+
for trivial chat" *without* splitting hara into two tools. Set `routeModel` (+ optional `routeBaseURL` /
|
|
298
|
+
`routeApiKey`, which default to the primary's) and each turn routes by its latest user message: trivial,
|
|
299
|
+
non-coding turns (short · single-line · no code / URL · no action keyword) go to `routeModel`; anything with
|
|
300
|
+
a coding/action signal stays on the primary `model`. **Conservative by design** — a coding tool errs toward
|
|
301
|
+
the strong model, so routing only fires on clear Q&A / chit-chat.
|
|
302
|
+
- Implemented as a transparent provider wrapper at the single main-chat entry (`withRouting`), so every
|
|
303
|
+
interactive / `-p` / TUI turn gets it while role / review / sub-agent providers stay untouched. The decision
|
|
304
|
+
reads the last `role:"user"` message (tool results are `role:"tool"`), so it's stable across a turn's tool
|
|
305
|
+
rounds. New `src/agent/route.ts` + tests. Env: `HARA_ROUTE_MODEL` / `HARA_ROUTE_BASE_URL` / `HARA_ROUTE_API_KEY`.
|
|
306
|
+
|
|
307
|
+
## 0.71.0 — unreleased (Sprint 1: governance + safety — command permissions · untrusted-content wrapping · structured compaction)
|
|
308
|
+
|
|
309
|
+
Three zero-dep items distilled from a 4-expert (codex/cc-haha/hermes/openclaw) C-end gap analysis; all reinforce hara's governance moat.
|
|
310
|
+
|
|
311
|
+
- **Command-level permission rules for `bash`** (`~/.hara/permissions.json` + project `.hara/permissions.json`):
|
|
312
|
+
an `allow` / `deny` + read-only-autorun policy that **composes with** approval modes. A **deny** rule blocks a
|
|
313
|
+
command even in `--full-auto`; an **allow** rule (or a recognized **read-only** command — `ls`/`grep`/`git
|
|
314
|
+
status`…) auto-runs even in `suggest` mode — ending the "confirm every command vs. unguarded full-auto" false
|
|
315
|
+
choice. Commands are canonicalized (unwrap `bash -lc`, strip `NODE_ENV=…`/`timeout` wrappers) so approving
|
|
316
|
+
`npm test` once sticks across phrasings; a compound command (`&&`/`||`/`;`/`|`) takes its **strictest** part's
|
|
317
|
+
decision; anything unparseable (`$()`/backticks/unbalanced quotes) fails **closed** to a prompt. New
|
|
318
|
+
`hara permissions` (list · `--init [--project]`). `src/security/permissions.ts` + tests.
|
|
319
|
+
- **Untrusted-content wrapping** for `web_fetch` / `web_search`: external page/search text is wrapped in a
|
|
320
|
+
"treat as DATA, not instructions" notice with a **random per-call boundary id**, and homoglyph / zero-width
|
|
321
|
+
tricks (fullwidth/CJK/math angle brackets, ZWSP/BOM/soft-hyphen) are defanged so a hostile page can't forge
|
|
322
|
+
the boundary or smuggle hidden instructions — closing the realistic indirect-prompt-injection vector for an
|
|
323
|
+
agent that holds `bash`. `src/security/external-content.ts` + tests.
|
|
324
|
+
- **Structured `/compact` template**: replaces the one-line summary with a 6-section brief (goal · key decisions
|
|
325
|
+
· files & code · errors & fixes · current state · next step) that **quotes the user's most recent request
|
|
326
|
+
verbatim** and drafts in an `<analysis>` scratchpad first — so resuming after a compaction doesn't drift or
|
|
327
|
+
drop the error→fix history.
|
|
328
|
+
|
|
8
329
|
## 0.70.0 — unreleased (B-end: devices pull their governed org-role bundle)
|
|
9
330
|
|
|
10
331
|
- An enrolled device now **syncs its digital-employee roles from hara-control** — `GET {gateway}/v1/roles`
|
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
> with routing boundaries, a dispatcher, a single source-of-truth data layer, human-in-the-loop
|
|
7
7
|
> approvals, and cron autonomy.
|
|
8
8
|
|
|
9
|
-
🚧 **v0.
|
|
9
|
+
🚧 **v0.89** · TypeScript · local-first · Apache-2.0
|
|
10
10
|
|
|
11
11
|
**Highlights**
|
|
12
12
|
- **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.
|
|
13
|
+
- **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk** (8 platforms), with **two-way images** (send a photo → it sees it; ask for a file → it sends one), per-chat resumable sessions, and project roaming. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
|
|
13
14
|
- **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only → propose a plan → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
|
|
14
15
|
- **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.
|
|
15
16
|
- **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
|
|
@@ -240,10 +241,11 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
|
|
|
240
241
|
- **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
|
|
241
242
|
- **`@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.
|
|
242
243
|
- **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
|
|
244
|
+
- **Chat gateway**: drive your local hara from a chat app — **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), and **two-way images** (send a photo → it sees it; ask for a file → it sends one). Setup per platform: **[docs/gateway.md](docs/gateway.md)**.
|
|
243
245
|
|
|
244
246
|
### Roadmap
|
|
245
247
|
|
|
246
|
-
**Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **single-binary distribution** · **Docker image** · `/compact` context management.
|
|
248
|
+
**Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (8 platforms, two-way images)** · **single-binary distribution** · **Docker image** · `/compact` context management.
|
|
247
249
|
**Next:** SSOT data authority · an enterprise control-plane (fleet + central token management).
|
|
248
250
|
|
|
249
251
|
## Security
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Auto-compaction trigger — the small, testable decision behind "compact the conversation before it overflows"
|
|
2
|
+
// (à la Claude Code's auto-compact). The actual summarize-and-replace I/O lives in index.ts (compactConversation),
|
|
3
|
+
// which reuses the manual /compact path; this just decides *when* to fire.
|
|
4
|
+
/** Auto-compact once the last turn used ≥ this % of the model's context window. */
|
|
5
|
+
export const AUTO_COMPACT_PCT = 85;
|
|
6
|
+
/** Whether to auto-compact now: enabled, the history is substantial enough to be worth summarizing, and the
|
|
7
|
+
* last turn filled the context past the threshold (so the NEXT turn would risk overflow). */
|
|
8
|
+
export function shouldAutoCompact(ctxPct, historyLen, autoCompact, threshold = AUTO_COMPACT_PCT) {
|
|
9
|
+
return autoCompact && historyLen >= 4 && ctxPct >= threshold;
|
|
10
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { contextWindow } from "../statusbar.js";
|
|
2
|
+
const est = (s) => Math.ceil((s?.length ?? 0) / 4);
|
|
3
|
+
/** Estimate token spend by category (your messages / assistant / each tool's output) across the history. */
|
|
4
|
+
export function analyzeContext(history) {
|
|
5
|
+
const by = new Map();
|
|
6
|
+
const add = (k, n) => {
|
|
7
|
+
by.set(k, (by.get(k) ?? 0) + n);
|
|
8
|
+
};
|
|
9
|
+
for (const m of history) {
|
|
10
|
+
if (m.role === "user")
|
|
11
|
+
add("your messages", est(m.content));
|
|
12
|
+
else if (m.role === "assistant")
|
|
13
|
+
add("assistant", est(m.text));
|
|
14
|
+
else if (m.role === "tool")
|
|
15
|
+
for (const r of m.results)
|
|
16
|
+
add(`tool: ${r.name}`, est(r.content));
|
|
17
|
+
}
|
|
18
|
+
const total = [...by.values()].reduce((a, b) => a + b, 0);
|
|
19
|
+
const rows = [...by.entries()]
|
|
20
|
+
.map(([label, tokens]) => ({ label, tokens, pct: Math.round((tokens / (total || 1)) * 100) }))
|
|
21
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
22
|
+
return { total, rows };
|
|
23
|
+
}
|
|
24
|
+
/** Human-readable breakdown, biggest first, with the share of the model's window used + a trim hint. */
|
|
25
|
+
export function formatContextReport(history, model) {
|
|
26
|
+
const { total, rows } = analyzeContext(history);
|
|
27
|
+
if (!rows.length)
|
|
28
|
+
return "Context is empty.";
|
|
29
|
+
const pctWin = Math.min(99, Math.round((total / contextWindow(model)) * 100));
|
|
30
|
+
const lines = rows.slice(0, 8).map((r) => ` ${String(r.pct).padStart(3)}% ${r.label} (~${r.tokens.toLocaleString()} tok)`);
|
|
31
|
+
const tip = pctWin >= 80 ? "\n → near the limit; /compact to summarize or /clear to reset." : "";
|
|
32
|
+
return `Context ~${total.toLocaleString()} tok (~${pctWin}% of ${model}'s window):\n${lines.join("\n")}${tip}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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).
|
|
5
|
+
/** Classify a provider error from its message (+ HTTP status if known). Message patterns include the
|
|
6
|
+
* Chinese strings DashScope/GLM/Qwen return, since hara targets those endpoints. */
|
|
7
|
+
export function classifyError(msg, status) {
|
|
8
|
+
const m = (msg || "").toLowerCase();
|
|
9
|
+
if (m === "interrupted")
|
|
10
|
+
return "interrupted";
|
|
11
|
+
if (status === 401 || status === 403 || /unauthor|invalid api key|invalid.*key|forbidden|permission denied|无效|鉴权/.test(m))
|
|
12
|
+
return "auth";
|
|
13
|
+
if (status === 429 || /rate.?limit|too many requests|\b429\b|请求过于频繁|限流/.test(m))
|
|
14
|
+
return "rate_limit";
|
|
15
|
+
if (status === 529 || status === 503 || /overload|capacity|service unavailable|temporarily unavailable|\b503\b|\b529\b|繁忙|过载/.test(m))
|
|
16
|
+
return "overloaded";
|
|
17
|
+
if (/context length|context window|maximum context|maximum.*token|too long|reduce the length|超过最大长度|上下文长度|输入过长/.test(m))
|
|
18
|
+
return "context_overflow";
|
|
19
|
+
if (/timeout|timed out|etimedout|econnreset|socket hang up|network/.test(m))
|
|
20
|
+
return "timeout";
|
|
21
|
+
if (typeof status === "number" && status >= 500)
|
|
22
|
+
return "transient";
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
// Error kinds where retrying on a DIFFERENT model can plausibly help (it may not be overloaded / may differ).
|
|
26
|
+
const FALLBACKABLE = new Set(["overloaded", "rate_limit", "timeout", "transient", "context_overflow", "unknown"]);
|
|
27
|
+
/** Decide the recovery for an errored turn: retry once on the fallback model, or fail. Never auto-recovers
|
|
28
|
+
* `auth` (a config problem) or `interrupted` (the user). Context-overflow IS fallback-able — a
|
|
29
|
+
* larger-context fallback model may fit (and preemptive auto-compaction already prevents most overflows). */
|
|
30
|
+
export function failoverAction(kind, s) {
|
|
31
|
+
if (kind === "interrupted" || kind === "auth")
|
|
32
|
+
return "fail";
|
|
33
|
+
if (s.hasFallback && !s.triedFallback && FALLBACKABLE.has(kind))
|
|
34
|
+
return "fallback";
|
|
35
|
+
return "fail";
|
|
36
|
+
}
|
|
37
|
+
/** A short actionable hint appended to the surfaced error message. */
|
|
38
|
+
export function errorHint(kind) {
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case "auth":
|
|
41
|
+
return " — check your API key / auth (try `hara setup`)";
|
|
42
|
+
case "rate_limit":
|
|
43
|
+
return " — rate-limited; wait a moment, or set `fallbackModel` to auto-switch";
|
|
44
|
+
case "overloaded":
|
|
45
|
+
return " — provider overloaded; set `fallbackModel` to auto-switch on errors";
|
|
46
|
+
case "context_overflow":
|
|
47
|
+
return " — context too long; `/compact` (or enable `autoCompact`)";
|
|
48
|
+
case "timeout":
|
|
49
|
+
return " — network timeout; check connectivity";
|
|
50
|
+
default:
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/agent/loop.js
CHANGED
|
@@ -6,6 +6,9 @@ import { makeRenderer } from "../md.js";
|
|
|
6
6
|
import { skillsDigest } from "../skills/skills.js";
|
|
7
7
|
import { runHooks } from "../hooks.js";
|
|
8
8
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
9
|
+
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
|
+
import { subdirHint } from "../context/subdir-hints.js";
|
|
11
|
+
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
9
12
|
/** Whether a tool call needs user confirmation under the given approval mode. */
|
|
10
13
|
export function needsConfirm(kind, mode) {
|
|
11
14
|
if (kind === "read")
|
|
@@ -30,10 +33,24 @@ When a task matches one of the Skills listed below, call the \`skill\` tool to l
|
|
|
30
33
|
before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
|
|
31
34
|
convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff). After completing
|
|
32
35
|
a task, give a one-line summary.`;
|
|
36
|
+
/** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
|
|
37
|
+
* (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
|
|
38
|
+
function gatewayNote() {
|
|
39
|
+
const plat = process.env.HARA_GATEWAY;
|
|
40
|
+
if (!plat)
|
|
41
|
+
return "";
|
|
42
|
+
return (`\n\n# You are in a chat gateway (${plat})\n` +
|
|
43
|
+
`You are talking to the user through the ${plat} chat — not a terminal, and NOT the desktop ${plat} app. ` +
|
|
44
|
+
`To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
|
|
45
|
+
`that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
|
|
46
|
+
`to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
|
|
47
|
+
`file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly.`);
|
|
48
|
+
}
|
|
33
49
|
function composeSystem(cwd, projectContext, override, memory) {
|
|
34
50
|
const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
|
|
35
51
|
const skills = skillsDigest(cwd);
|
|
36
52
|
return (head +
|
|
53
|
+
gatewayNote() +
|
|
37
54
|
(projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
|
|
38
55
|
(memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
|
|
39
56
|
(skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
|
|
@@ -41,6 +58,16 @@ function composeSystem(cwd, projectContext, override, memory) {
|
|
|
41
58
|
/** Provider-agnostic agentic loop. Mutates `history` in place. */
|
|
42
59
|
export async function runAgent(history, opts) {
|
|
43
60
|
const { provider, ctx } = opts;
|
|
61
|
+
const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
|
|
62
|
+
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
63
|
+
let triedFallback = false;
|
|
64
|
+
// Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
|
|
65
|
+
// nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
|
|
66
|
+
// non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
|
|
67
|
+
const guard = !!process.env.HARA_GATEWAY;
|
|
68
|
+
const toolCounts = new Map();
|
|
69
|
+
let blindShots = 0;
|
|
70
|
+
let nudged = false;
|
|
44
71
|
for (;;) {
|
|
45
72
|
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
46
73
|
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
@@ -68,7 +95,7 @@ export async function runAgent(history, opts) {
|
|
|
68
95
|
let fi = 0;
|
|
69
96
|
spin = setInterval(() => out(`\r${c.dim(`${frames[fi++ % frames.length]} working ${Math.floor((Date.now() - t0) / 1000)}s`)}`), 100);
|
|
70
97
|
}
|
|
71
|
-
const r = await
|
|
98
|
+
const r = await activeProvider.turn({
|
|
72
99
|
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
73
100
|
history,
|
|
74
101
|
tools: specs,
|
|
@@ -115,12 +142,26 @@ export async function runAgent(history, opts) {
|
|
|
115
142
|
}
|
|
116
143
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
117
144
|
if (r.stop === "error") {
|
|
118
|
-
const
|
|
145
|
+
const kind = classifyError(r.errorMsg ?? "");
|
|
146
|
+
if (failoverAction(kind, { hasFallback: !!opts.fallback?.provider, triedFallback }) === "fallback") {
|
|
147
|
+
triedFallback = true;
|
|
148
|
+
history.pop(); // drop the errored (partial/empty) assistant turn before retrying
|
|
149
|
+
activeProvider = opts.fallback.provider;
|
|
150
|
+
if (!opts.quiet) {
|
|
151
|
+
const note = `✻ ${kind} → falling back to ${activeProvider.model}…`;
|
|
152
|
+
if (sink)
|
|
153
|
+
sink.notice(note);
|
|
154
|
+
else
|
|
155
|
+
out(c.dim(`${note}\n`));
|
|
156
|
+
}
|
|
157
|
+
continue; // retry once on the fallback model (guarded by triedFallback)
|
|
158
|
+
}
|
|
159
|
+
const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
|
|
119
160
|
if (!opts.quiet) {
|
|
120
161
|
if (sink)
|
|
121
162
|
sink.notice(msg);
|
|
122
163
|
else
|
|
123
|
-
out(
|
|
164
|
+
out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
|
|
124
165
|
}
|
|
125
166
|
return;
|
|
126
167
|
}
|
|
@@ -139,7 +180,14 @@ export async function runAgent(history, opts) {
|
|
|
139
180
|
.trim();
|
|
140
181
|
// Screen control is gated on EVERY action — a prior "don't ask again" must never satisfy it.
|
|
141
182
|
const alwaysGate = tool.kind === "computer";
|
|
142
|
-
|
|
183
|
+
// Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
|
|
184
|
+
// read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
|
|
185
|
+
const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
|
|
186
|
+
if (cmdDecision === "deny") {
|
|
187
|
+
plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
|
|
143
191
|
const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
|
|
144
192
|
if (reply === false) {
|
|
145
193
|
plans.push({ tu, tool, denied: "User denied this action." });
|
|
@@ -172,7 +220,8 @@ export async function runAgent(history, opts) {
|
|
|
172
220
|
return;
|
|
173
221
|
}
|
|
174
222
|
const res = await p.tool.run(p.tu.input, ctx);
|
|
175
|
-
|
|
223
|
+
// append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
|
|
224
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) };
|
|
176
225
|
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
177
226
|
}
|
|
178
227
|
catch (e) {
|
|
@@ -202,5 +251,26 @@ export async function runAgent(history, opts) {
|
|
|
202
251
|
}
|
|
203
252
|
await flush();
|
|
204
253
|
history.push({ role: "tool", results });
|
|
254
|
+
if (guard && !nudged) {
|
|
255
|
+
for (const p of plans)
|
|
256
|
+
if (p.tool && p.tool.kind !== "read")
|
|
257
|
+
toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
|
|
258
|
+
for (const res of results)
|
|
259
|
+
if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
|
|
260
|
+
blindShots++;
|
|
261
|
+
const maxRepeat = Math.max(0, ...toolCounts.values());
|
|
262
|
+
const blind = blindShots >= 2;
|
|
263
|
+
if (blind || maxRepeat >= 5) {
|
|
264
|
+
nudged = true;
|
|
265
|
+
history.push({
|
|
266
|
+
role: "user",
|
|
267
|
+
content: blind
|
|
268
|
+
? "⚠ Self-check: your screenshots come back unreadable (no vision model) — you are acting blind, so this approach cannot work. Stop using the computer tool. Reach the user through a non-visual path instead (a CLI, an API, or the send_file tool). State the new plan in one line, then do it."
|
|
269
|
+
: "⚠ Self-check: you've repeated the same action several times without resolving the task. Stop and reconsider — is there a more direct tool or channel (e.g. send_file to deliver a file)? Don't keep retrying the same thing. State your revised plan in one line, then act.",
|
|
270
|
+
});
|
|
271
|
+
if (!opts.quiet && !ctx.ui)
|
|
272
|
+
out(c.dim(" ⟲ stuck-guard: nudging a rethink\n"));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
205
275
|
}
|
|
206
276
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Recent user turns, newest first (n=1 = most recent), each with a short preview — for `/rewind` to list. */
|
|
2
|
+
export function userTurnPreviews(history, max = 10) {
|
|
3
|
+
const idxs = [];
|
|
4
|
+
history.forEach((m, i) => m.role === "user" && idxs.push(i));
|
|
5
|
+
const out = [];
|
|
6
|
+
for (let k = idxs.length - 1, n = 1; k >= 0 && n <= max; k--, n++) {
|
|
7
|
+
const m = history[idxs[k]];
|
|
8
|
+
out.push({ n, preview: (m.role === "user" ? m.content : "").replace(/\s+/g, " ").slice(0, 70) });
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** Truncate history to just BEFORE the n-th-most-recent user turn (n=1 drops the last exchange), forking the
|
|
13
|
+
* conversation from that point. Returns the new history array, or null if n is out of range. */
|
|
14
|
+
export function rewindTo(history, n) {
|
|
15
|
+
const idxs = [];
|
|
16
|
+
history.forEach((m, i) => m.role === "user" && idxs.push(i));
|
|
17
|
+
if (!Number.isInteger(n) || n < 1 || n > idxs.length)
|
|
18
|
+
return null;
|
|
19
|
+
return history.slice(0, idxs[idxs.length - n]); // cut at the n-th-from-last user message
|
|
20
|
+
}
|