@nanhara/hara 0.112.4 → 0.113.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 CHANGED
@@ -5,6 +5,32 @@ 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.113.0 — DeepSeek reasoning control (thinking + effort, incl. `max`) · host-unreachable memory
9
+
10
+ - **DeepSeek reasoning is now a real dial.** DeepSeek's V4 models (`deepseek-v4-pro` / `deepseek-v4-flash`)
11
+ added a per-request thinking switch on the OpenAI-compatible chat path — `thinking: {type}` plus
12
+ `reasoning_effort` (native `high`|`max`; `low`/`medium` map → high server-side). hara now sends both via a
13
+ new `deepseek` reasoning style: **`off` → `thinking:{type:"disabled"}`** (reasoning_effort has no "off"),
14
+ any level → `thinking:{type:"enabled"}` + the effort. The `/model` picker's ←→ thinking dial now lights up
15
+ for DeepSeek. Verified against the live API: `off` emits no `reasoning_content`, `high`/`max` stream it
16
+ (`max` thinks measurably harder than `high`), and tool-calls work with reasoning on.
17
+ - **New `max` reasoning level.** `hara config set reasoningEffort max` (and the picker's ←→) now accept
18
+ **`max`**, the top of the dial. On DeepSeek it becomes `reasoning_effort:"max"`; on OpenAI reasoning models
19
+ it clamps to `high` (OpenAI has no `max`, so it never 400s); on Anthropic it takes the largest thinking
20
+ budget. Existing `off`/`low`/`medium`/`high` are unchanged.
21
+ - **Host-unreachable memory — stop re-hanging on a dead host.** When a network command (git clone/pull/fetch,
22
+ curl, …) fails to CONNECT — a TCP connect timeout or DNS failure (macOS's ~75s SYN timeout), *not* an
23
+ auth/404/connection-refused — hara now remembers that host for the session and **fast-fails later network
24
+ ops to it instantly** instead of eating another ~75s per retry. The failure output also flags that git
25
+ ignores the macOS system / Clash proxy unless configured (`git config --global http.proxy`). Cleared by
26
+ `/reset`. Paired with a system-prompt rule: reuse a local checkout before cloning, don't swap in a public
27
+ mirror for a private repo, and verify connectivity yourself rather than trusting "the network is fine".
28
+
29
+ ## 0.112.5 — single-writer session lock
30
+
31
+ - **A double-resume can no longer corrupt session history.** A single-writer lock serializes session writes
32
+ so two processes resuming the same session don't interleave and clobber the transcript.
33
+
8
34
  ## 0.112.4 — reasoning models don't false-timeout · cross-provider fallback routes correctly
9
35
 
10
36
  - **A reasoning model (qwen3.7-plus / GLM / DeepSeek) thinking on a long context no longer false-times-out.**
package/README.md CHANGED
@@ -127,6 +127,14 @@ hara config set visionModel qwen-vl-max # a vision model on the same plan/key
127
127
  If a model's capability is unknown, hara **asks once and remembers**. In the TUI, `/vision <model>` sets the
128
128
  describer and `/vision main yes|no|auto` corrects a model's detected capability.
129
129
 
130
+ **Reasoning effort** — dial how hard a thinking model reasons: `off` · `low` · `medium` · `high` · `max`.
131
+ ```bash
132
+ hara config set reasoningEffort high # or off / low / medium / max
133
+ ```
134
+ hara expresses it the way each endpoint wants (OpenAI `reasoning_effort`, Anthropic thinking budget,
135
+ DashScope `enable_thinking`, **DeepSeek** V4 `thinking` + `reasoning_effort` where `max` genuinely raises the
136
+ effort). In the TUI, bare `/model` opens a picker — ↑↓ pick a model, **←→ set the thinking level**.
137
+
130
138
  Config lives in `~/.hara/config.json`. Env vars override it: `HARA_PROVIDER`, `HARA_MODEL`,
131
139
  `HARA_BASE_URL`, `HARA_API_KEY`, or the provider key (`ANTHROPIC_API_KEY` / `DASHSCOPE_API_KEY`).
132
140
 
@@ -67,8 +67,15 @@ memory: use memory_search before answering about prior decisions,
67
67
  conventions, or the user's preferences, and memory_write to proactively save durable facts you learn.
68
68
  When a task matches one of the Skills listed below, call the \`skill\` tool to load its full instructions
69
69
  before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
70
- convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff). After completing
71
- a task, give a one-line summary.`;
70
+ convention, you may propose an edit to AGENTS.md via edit_file (the user reviews the diff).
71
+ Network resilience: before \`git clone\`, check the target dir isn't already present (ls / test -d) and
72
+ reuse a local checkout instead of re-cloning. If a network command fails to CONNECT (timeout or DNS — not
73
+ auth/404), treat that host as down for the session: don't retry it, don't swap in a public mirror (mirrors
74
+ can't serve private repos), don't switch protocols — hara already fast-fails repeats to a dead host, so
75
+ diagnose instead. git ignores the macOS system / Clash proxy unless configured (git config --global
76
+ http.proxy), so a browser that reaches a site doesn't mean the terminal does — verify connectivity yourself
77
+ rather than trusting "the network is fine". If a step's output artifact already exists and is newer than its
78
+ inputs, skip re-running it. After completing a task, give a one-line summary.`;
72
79
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
73
80
  * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
74
81
  function gatewayNote() {
package/dist/config.js CHANGED
@@ -31,7 +31,7 @@ const PROVIDER_DEFAULTS = {
31
31
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
32
32
  };
33
33
  export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
34
- export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
34
+ export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
35
35
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
36
36
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
37
37
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -164,7 +164,7 @@ export function loadConfig(opts = {}) {
164
164
  const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
165
165
  const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
166
166
  const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
167
- const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
167
+ const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
168
168
  ? reasoningRaw
169
169
  : undefined;
170
170
  return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath
39
39
  import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
40
40
  import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
41
41
  import { getTools } from "./tools/registry.js";
42
+ import { resetReachability } from "./tools/net-reachability.js";
42
43
  import { EXPLORE_SYSTEM } from "./tools/agent.js";
43
44
  import { createAnthropicProvider } from "./providers/anthropic.js";
44
45
  import { createOpenAIProvider } from "./providers/openai.js";
@@ -65,7 +66,7 @@ import { getEmbedder } from "./search/embed.js";
65
66
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
66
67
  import { searchHybrid } from "./search/hybrid.js";
67
68
  import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
68
- import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
69
+ import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
69
70
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
70
71
  import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
71
72
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
@@ -2392,6 +2393,16 @@ program.action(async (opts) => {
2392
2393
  createdAt: new Date().toISOString(),
2393
2394
  updatedAt: "",
2394
2395
  };
2396
+ // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
2397
+ // corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
2398
+ // of a session already open elsewhere). Released on exit.
2399
+ const lock = acquireSessionLock(meta.id);
2400
+ if (!lock.ok) {
2401
+ out(c.red(`Session ${shortId(meta.id)} is already open in another hara process (pid ${lock.pid}).`) +
2402
+ c.dim(` Resuming the same session twice races writes and can corrupt its history. Close that one, or run \`hara\` for a new session. (Override: rm ~/.hara/sessions/${meta.id}.lock)\n`));
2403
+ process.exit(1);
2404
+ }
2405
+ process.on("exit", () => releaseSessionLock(meta.id));
2395
2406
  // Per-session model precedence on resume:
2396
2407
  // 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
2397
2408
  // 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
@@ -2916,6 +2927,7 @@ program.action(async (opts) => {
2916
2927
  if (nm === "reset" || nm === "clear") {
2917
2928
  history.length = 0;
2918
2929
  recalledContext = "";
2930
+ resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
2919
2931
  return void h.sink.notice("(context cleared)");
2920
2932
  }
2921
2933
  if (nm === "undo") {
@@ -106,6 +106,8 @@ export function buildThinkingParam(model, effort) {
106
106
  return { type: "enabled", budget_tokens: 4096 };
107
107
  if (effort === "medium")
108
108
  return { type: "adaptive" };
109
+ if (effort === "max")
110
+ return { type: "enabled", budget_tokens: 32000 }; // top of the dial — biggest budget
109
111
  // high
110
112
  return { type: "enabled", budget_tokens: 24000 };
111
113
  }
@@ -26,11 +26,19 @@ export function reasoningParams(style, effort, model = "") {
26
26
  case "reasoning_effort":
27
27
  if (!isReasoningModel(model))
28
28
  return {};
29
- return { reasoning_effort: effort === "off" ? "minimal" : effort };
29
+ // OpenAI's ceiling is "high"; there's no "max" clamp so the global `max` dial never 400s here.
30
+ return { reasoning_effort: effort === "off" ? "minimal" : effort === "max" ? "high" : effort };
30
31
  case "reasoning_object":
31
32
  if (!isReasoningModel(model))
32
33
  return {};
33
- return { reasoning: { effort: effort === "off" ? "minimal" : effort } };
34
+ return { reasoning: { effort: effort === "off" ? "minimal" : effort === "max" ? "high" : effort } };
35
+ case "deepseek":
36
+ // off → turn thinking OFF via the object (reasoning_effort can't say "off"). Any level → thinking ON
37
+ // + the effort enum; DeepSeek natively honors high|max and maps low/medium→high server-side, so we
38
+ // pass the dial through as-is (forward-compatible if DeepSeek later distinguishes low/medium).
39
+ if (effort === "off")
40
+ return { thinking: { type: "disabled" } };
41
+ return { thinking: { type: "enabled" }, reasoning_effort: effort };
34
42
  case "thinking_budget": // Anthropic — applied by anthropic.ts, not on a chat/responses merge body
35
43
  case "none":
36
44
  default:
@@ -20,9 +20,9 @@ const BY_BASEURL = [
20
20
  // GLM, MiniMax, Aliyun apps/anthropic … all expose `.../anthropic` with thinking + explicit cache. One
21
21
  // row covers the whole ecosystem — talk to it with the anthropic transport.
22
22
  { test: /\/anthropic\/?($|\?)/i, caps: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" } },
23
- // DeepSeek OpenAI-compatible (chat): the model id picks reasoning (deepseek-reasoner reasons, -chat
24
- // doesn't); there's no per-request toggle on this path leave it alone.
25
- { test: /api\.deepseek\.com/i, caps: { wireApi: "chat", reasoning: "none", cache: "auto" } },
23
+ // DeepSeek OpenAI-compatible (chat): DeepSeek V4 (v4-pro/v4-flash) exposes a per-request thinking switch
24
+ // (`thinking: {type}`) + `reasoning_effort` (high|max) on this path the `deepseek` style sends both.
25
+ { test: /api\.deepseek\.com/i, caps: { wireApi: "chat", reasoning: "deepseek", cache: "auto" } },
26
26
  ];
27
27
  /** Per-provider-id overrides (built-in providers whose id alone fixes the shape). */
28
28
  const BY_PROVIDER = {
@@ -30,7 +30,7 @@ const BY_PROVIDER = {
30
30
  qwen: { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" }, // DashScope
31
31
  "qwen-oauth": { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" },
32
32
  glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
33
- deepseek: { wireApi: "chat", reasoning: "none", cache: "auto" },
33
+ deepseek: { wireApi: "chat", reasoning: "deepseek", cache: "auto" }, // V4: thinking:{type} + reasoning_effort(high|max)
34
34
  ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
35
35
  openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
36
36
  openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
@@ -1,7 +1,7 @@
1
1
  // Session persistence — conversations saved as JSON under ~/.hara/sessions, resumable.
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  function sessionsDir() {
7
7
  const d = join(homedir(), ".hara", "sessions");
@@ -9,6 +9,54 @@ function sessionsDir() {
9
9
  return d;
10
10
  }
11
11
  const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
12
+ const lockFile = (id) => join(sessionsDir(), `${id}.lock`);
13
+ /** Is a process with this pid alive? `process.kill(pid, 0)` sends no signal — it just probes: throws
14
+ * ESRCH if dead, EPERM if alive-but-not-ours (still alive). Best-effort across platforms. */
15
+ function pidAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch (e) {
21
+ return e?.code === "EPERM";
22
+ }
23
+ }
24
+ /** Take an exclusive lock on a session so two hara processes can't resume the SAME session and corrupt
25
+ * its append-only history by racing writes. Returns `{ ok: true }` if acquired (or the previous holder
26
+ * is dead → we take over), or `{ ok: false, pid }` if a LIVE process already holds it. Best-effort: any
27
+ * filesystem hiccup resolves to `ok:true` so a lock problem never blocks the user. Pair with
28
+ * `releaseSessionLock` on exit. */
29
+ export function acquireSessionLock(id) {
30
+ const f = lockFile(id);
31
+ try {
32
+ if (existsSync(f)) {
33
+ const held = JSON.parse(readFileSync(f, "utf8"));
34
+ if (typeof held.pid === "number" && held.pid !== process.pid && pidAlive(held.pid)) {
35
+ return { ok: false, pid: held.pid };
36
+ }
37
+ // stale (dead pid) or already ours → fall through and (re)claim it
38
+ }
39
+ writeFileSync(f, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
40
+ return { ok: true };
41
+ }
42
+ catch {
43
+ return { ok: true };
44
+ }
45
+ }
46
+ /** Release a session lock we hold (only removes it if the pid matches ours — never steals another's). */
47
+ export function releaseSessionLock(id) {
48
+ try {
49
+ const f = lockFile(id);
50
+ if (!existsSync(f))
51
+ return;
52
+ const held = JSON.parse(readFileSync(f, "utf8"));
53
+ if (held?.pid === process.pid)
54
+ rmSync(f);
55
+ }
56
+ catch {
57
+ /* best-effort */
58
+ }
59
+ }
12
60
  /** A full UUID per session (the stable identity). */
13
61
  export const newSessionId = () => randomUUID();
14
62
  /** First segment of the UUID — a compact label for the status bar / `/sessions`. */
@@ -7,7 +7,28 @@ import { nearestPaths } from "../fs-walk.js";
7
7
  import { emitDiff } from "../diff.js";
8
8
  import { recordEdit } from "../undo.js";
9
9
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
10
+ import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
10
11
  const MAX = 100_000;
12
+ /** Resolve the remote HOST a bare `git pull/fetch/push` targets (no URL in the command → host lives in the
13
+ * repo's remote config). Local + fast (no network); best-effort — returns "" on any hiccup. Only ever
14
+ * called after a host has already been marked unreachable, so it adds zero overhead on the happy path. */
15
+ async function gitRemoteHost(command, cwd, sandbox) {
16
+ const m = command.match(/\bgit\b[^\n]*\b(?:fetch|pull|push)\b\s+(?!-)(\S+)/);
17
+ const remote = m && /^[\w./-]+$/.test(m[1]) ? m[1] : "origin";
18
+ try {
19
+ const { stdout } = await runShell(`git remote get-url ${remote}`, cwd, sandbox, { timeout: 5000, maxBuffer: 65536 });
20
+ return hostsInCommand(stdout.trim())[0] ?? "";
21
+ }
22
+ catch {
23
+ return "";
24
+ }
25
+ }
26
+ /** git ignores the macOS system / Clash proxy unless told to — so a browser that reaches GitHub doesn't
27
+ * mean the terminal does. Appended to connectivity-failure output so the agent diagnoses instead of retrying. */
28
+ function proxyHint(host) {
29
+ const h = host || "the remote host";
30
+ return `\n\n↯ hara: this is a CONNECTIVITY failure to ${h} (timeout/DNS), not an auth error — I will NOT retry network ops to ${h} for the rest of this session (a repeat just hangs ~75s again). git does NOT use the macOS system/Clash proxy unless configured; check \`git config --global http.proxy\` and \`echo $https_proxy $ALL_PROXY\`. If you've since started a VPN/proxy or fixed DNS, tell me and I'll clear the mark and retry.`;
31
+ }
11
32
  function abs(p, cwd) {
12
33
  return isAbsolute(p) ? p : resolve(cwd, p);
13
34
  }
@@ -89,6 +110,22 @@ registerTool({
89
110
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
90
111
  return `Started background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all.`;
91
112
  }
113
+ // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
114
+ // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
115
+ // once something is actually marked (unreachableHostsSnapshot empty ⇒ zero overhead on the happy path).
116
+ if (unreachableHostsSnapshot().length) {
117
+ const explicit = hostsInCommand(input.command);
118
+ let blocked = explicit.find(isHostUnreachable) ?? "";
119
+ if (!blocked && !explicit.length && isNetworkGitOp(input.command)) {
120
+ const h = await gitRemoteHost(input.command, ctx.cwd, ctx.sandbox ?? "off");
121
+ if (h && isHostUnreachable(h))
122
+ blocked = h;
123
+ }
124
+ if (blocked) {
125
+ ctx.ui?.notice(`↯ skipping — ${blocked} was unreachable earlier this session`);
126
+ return `Skipped without running: host "${blocked}" already failed to connect earlier in THIS session — hara does not retry network operations to a host known unreachable this session (a retry just hangs ~75s again). Do not swap in a public mirror (won't serve private repos) or switch protocols; diagnose instead.${proxyHint(blocked)}`;
127
+ }
128
+ }
92
129
  let buf = ""; // TUI: line-buffer live output into the sink (one notice per line)
93
130
  const live = ctx.ui
94
131
  ? (s) => {
@@ -114,7 +151,20 @@ registerTool({
114
151
  return capHeadTail(combined.trim() || "(no output)");
115
152
  }
116
153
  catch (e) {
117
- return capHeadTail(`Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`);
154
+ const base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
155
+ // Network fault tolerance — if this was a genuine host-unreachability (connect timeout / DNS, NOT
156
+ // auth / 404 / connection-refused), remember the host so we fast-fail future ops to it this session.
157
+ if (isConnectFailure(base)) {
158
+ let host = hostFromConnectError(base) || hostsInCommand(input.command)[0] || "";
159
+ if (!host && isNetworkGitOp(input.command))
160
+ host = await gitRemoteHost(input.command, ctx.cwd, ctx.sandbox ?? "off");
161
+ if (host) {
162
+ markHostUnreachable(host);
163
+ ctx.ui?.notice(`↯ ${host} marked unreachable for this session — won't retry network ops to it`);
164
+ return capHeadTail(base + proxyHint(host));
165
+ }
166
+ }
167
+ return capHeadTail(base);
118
168
  }
119
169
  },
120
170
  });
@@ -0,0 +1,67 @@
1
+ // Session-scoped "host unreachable" memory. When a network command (git / curl / …) fails because the
2
+ // HOST could not be reached — a TCP connect timeout or a DNS failure, NOT an auth/404/protocol error on a
3
+ // host that's actually up — we remember that host for the rest of this hara process. A later command aimed
4
+ // at the same host is then short-circuited INSTANTLY (see builtin.ts bash tool) instead of eating another
5
+ // ~75s OS-level connect timeout. This is the deterministic half of hara's network fault-tolerance: the
6
+ // model can't "forget" it the way a system-prompt rule can be ignored under pressure.
7
+ //
8
+ // One hara process == one session (same process-local pattern as session-model.ts's force-model flag), so
9
+ // a module-local Set is exactly session scope — no need to thread state through ~17 runAgent call sites.
10
+ // This module is PURE + fully unit-tested; the only impure step (resolving a bare `git pull`'s remote host
11
+ // with a local `git remote get-url`) lives in the bash tool, keeping the classification logic deterministic.
12
+ const unreachable = new Set();
13
+ /** Remember a host as unreachable for the rest of this session. */
14
+ export function markHostUnreachable(host) {
15
+ if (host)
16
+ unreachable.add(host.toLowerCase());
17
+ }
18
+ /** Has this host already failed to connect this session? */
19
+ export function isHostUnreachable(host) {
20
+ return !!host && unreachable.has(host.toLowerCase());
21
+ }
22
+ /** Snapshot of the hosts marked unreachable (for the pre-check fast path + tests). */
23
+ export function unreachableHostsSnapshot() {
24
+ return [...unreachable];
25
+ }
26
+ /** Clear the memory — called on /reset (network may have been fixed) and between tests. */
27
+ export function resetReachability() {
28
+ unreachable.clear();
29
+ }
30
+ /** Explicit hosts a command references: URLs (http/https/git/ssh/ftp) + scp/ssh `user@host:path` specs.
31
+ * Pure. A bare `git pull origin main` yields [] — its host lives in the repo's remote config, resolved
32
+ * separately by the bash tool. */
33
+ export function hostsInCommand(command) {
34
+ const hosts = new Set();
35
+ // scheme://[user@]host[:port]/… (github.com from https://github.com/owner/repo.git)
36
+ for (const m of command.matchAll(/\b(?:https?|ssh|git|ftp):\/\/(?:[^/\s'"@]+@)?([^/\s:'"]+)/gi)) {
37
+ hosts.add(m[1].toLowerCase());
38
+ }
39
+ // scp/ssh syntax: user@host:path (github.com from git@github.com:owner/repo.git)
40
+ for (const m of command.matchAll(/(?:^|\s)[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):/g)) {
41
+ hosts.add(m[1].toLowerCase());
42
+ }
43
+ return [...hosts];
44
+ }
45
+ /** Does this command reach out over the network for git? Catches the bare forms (`git pull`, `git fetch`)
46
+ * that carry no URL, so the tool knows to resolve their remote host before deciding to short-circuit. */
47
+ export function isNetworkGitOp(command) {
48
+ return /\bgit\b[^\n]*\b(?:clone|fetch|pull|push|ls-remote|remote\s+(?:update|show)|submodule\s+update)\b/.test(command);
49
+ }
50
+ /** Pull the connect/DNS-failure HOST out of a command's error text — the deterministic signal for WHICH
51
+ * host is down. Returns "" if the text names no host. Pure. */
52
+ export function hostFromConnectError(errText) {
53
+ const m = errText.match(/Failed to connect to ([^\s:]+)\s+port/i) || // git/curl: Failed to connect to github.com port 443
54
+ errText.match(/Could not resolve host:?\s*([^\s'"]+)/i) || // git/curl DNS: Could not resolve host: github.com
55
+ errText.match(/Resolving [^\s]*?\(?([A-Za-z0-9.-]+?)\)? timed out/i) || // curl (28) Resolving api.x.com timed out
56
+ errText.match(/unable to access '[a-z]+:\/\/([^/'"]+)/i); // git: unable to access 'https://github.com/…'
57
+ return m ? m[1].toLowerCase().replace(/[.,]+$/, "") : "";
58
+ }
59
+ /** Is this error a genuine host-unreachability — a connect TIMEOUT / DNS failure / no-route, the kind that
60
+ * burns ~75s — as opposed to (a) auth/404/protocol errors on a host that IS up, or (b) "connection
61
+ * refused", which is a host that's up and fast-rejecting (a dev server not started yet), NOT the slow
62
+ * waste we're guarding against. We cache ONLY genuine unreachability. Pure. */
63
+ export function isConnectFailure(errText) {
64
+ if (/Connection refused|ECONNREFUSED/i.test(errText))
65
+ return false; // host is UP (fast reject) — never cache
66
+ return /(?:Failed to connect to [^\n]*port|Could not resolve host|Couldn't connect to server|Connection timed out|Resolving [^\n]*timed out|Operation timed out|[Nn]etwork is unreachable|[Nn]o route to host|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|EAI_AGAIN)/.test(errText);
67
+ }
@@ -12,6 +12,8 @@ export function levelsFor(style) {
12
12
  return [];
13
13
  if (style === "enable_thinking" || style === "ollama_think")
14
14
  return ["off", "high"]; // "high" renders as "on"
15
+ if (style === "deepseek")
16
+ return ["off", "low", "medium", "high", "max"]; // DeepSeek V4 honors a real "max"
15
17
  return ["off", "low", "medium", "high"];
16
18
  }
17
19
  /** Label a level for display — binary styles read as on/off, graded ones as the level name. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.112.4",
3
+ "version": "0.113.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"