@nanhara/hara 0.109.3 → 0.109.5

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,40 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.109.5 — Enter enters the conversation instantly + reasoning off truly disables DashScope thinking
9
+
10
+ - **Pressing Enter now enters the conversation flow instantly — the message no longer sits stuck in the
11
+ input box.** A turn's synchronous prep (reading an inlined `@file`, base64-encoding pasted images) used
12
+ to run *before* ink could paint, so with a heavy message (a big spec + two images) Enter looked dead
13
+ for seconds ("回车一直不动") until the work finished. The submit now yields one tick so the UI paints
14
+ the committed message + cleared input + spinner FIRST, then does the prep and the (often slow) model
15
+ call. Instant feedback regardless of how heavy the turn or how slow the first token.
16
+ - **On DashScope, `reasoning off` now actually stops the thinking phase — not just hides it.** DashScope
17
+ models (Qwen, GLM, …) stream a "thinking" pass *before* the answer; that generation is the main latency
18
+ there (measured: **qwen3.7-plus ~14s with thinking → ~1.6s without**). hara previously assumed chat
19
+ models "can't be silenced server-side" and merely dropped the reasoning from the UI — so the model
20
+ still spent the time. It *can* be silenced: reasoning **off** now sends `enable_thinking: false` (fast),
21
+ low/medium/high sends `true`, and the dial **UNSET leaves the request untouched** (model default — zero
22
+ impact, the safe default). Detected by the **DashScope endpoint** (built-in `qwen`/`qwen-oauth`, or a
23
+ custom baseURL on `dashscope.aliyuncs.com`), not the model name — so a custom `qwen3.7-plus` profile is
24
+ covered. Set it with `HARA_REASONING_EFFORT=off` or `reasoningEffort: "off"` in `~/.hara/config.json`.
25
+ (A runtime `/reasoning` toggle — adjust it mid-session like Claude Code — is coming next.)
26
+ - Note: `enable_thinking:false` is reliable on qwen3.x-plus; on some other DashScope models it only
27
+ suppresses the reasoning without the full speedup, which is why it's opt-in, not a forced default.
28
+ - Context on prompt caching (from 0.109.1): that path only ever applied to the raw **Anthropic** provider.
29
+ DashScope/GLM/DeepSeek/gateway go through the OpenAI-compatible path, where caching is the provider's own
30
+ automatic prefix cache — and hara's system prompt is stable across turns, so it engages on its own.
31
+
32
+ ## 0.109.4 — a dropped file path is read, not "Unknown command"
33
+
34
+ - **Dragging/pasting a file into the prompt no longer errors.** A dropped file pastes as an absolute path
35
+ (`/Users/…/spec.md`, often with trailing text or `[Image #N]` tokens). Because it starts with `/`, hara
36
+ treated it as a slash command and replied `Unknown command /Users/…`. Now the command parser only fires
37
+ when the first token has **no embedded slash** (real commands — `/help`, `/design` — never contain one),
38
+ so a path falls through to the normal turn. And when a message *begins* with an existing absolute path,
39
+ that path is rewritten to an `@`-mention so its content is **read into the turn** — i.e. "interpret this
40
+ file" just works. Fixed in both the TUI and the readline REPL.
41
+
8
42
  ## 0.109.3 — an empty model response no longer looks like a hang
9
43
 
10
44
  - **A turn that comes back with no text and no tool calls is no longer silently dropped.** The agent
@@ -8,6 +8,23 @@ import { mediaTypeFor } from "../images.js";
8
8
  const MAX_FILE = 50_000;
9
9
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
10
10
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
11
+ /** A submitted line is a slash COMMAND only if it starts with '/' AND its first whitespace-delimited
12
+ * token has no *embedded* '/'. A dropped/pasted absolute file path (`/Users/…/doc.md`, possibly followed
13
+ * by text or `[Image #N]` tokens) has an embedded slash → it is NOT a command. Without this, dragging a
14
+ * file into the prompt errored with "Unknown command /Users/…". Exported pure for tests. */
15
+ export function isSlashCommand(line) {
16
+ return line.startsWith("/") && !line.slice(1).split(/\s+/)[0].includes("/");
17
+ }
18
+ /** If a message BEGINS with an absolute file path (a dragged/pasted file), rewrite that leading path into
19
+ * an `@`-mention so expandMentions inlines the file's content into the turn — the "read/interpret this
20
+ * file" the user meant. Space-free leading paths only (a mention can't contain spaces); a non-existent or
21
+ * spaced path is left untouched. `exists` is injected so this stays pure/testable. */
22
+ export function inlineLeadingPath(line, exists) {
23
+ if (!line.startsWith("/"))
24
+ return line;
25
+ const first = line.split(/\s+/)[0];
26
+ return exists(first) ? "@" + line : line;
27
+ }
11
28
  /** Expand `@path` references **in place** — the file/dir content lands where it's referenced, not
12
29
  * dumped at the bottom (so "compare @a.ts with @b.ts" reads in context). A repeated mention keeps
13
30
  * the bare `@path` the second time (no double-inlining), and a non-readable ref is left untouched. */
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./conte
47
47
  import { getEmbedder } from "./search/embed.js";
48
48
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
49
49
  import { searchHybrid } from "./search/hybrid.js";
50
- import { expandMentions, fileCandidates } from "./context/mentions.js";
50
+ import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
51
51
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
52
52
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
53
53
  import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
@@ -2814,7 +2814,9 @@ program.action(async (opts) => {
2814
2814
  onClipboardImage: readClipboardImage,
2815
2815
  vim: cfg.vimMode,
2816
2816
  onSubmit: async (line, h, images) => {
2817
- if (line.startsWith("/")) {
2817
+ // A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
2818
+ // is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
2819
+ if (isSlashCommand(line)) {
2818
2820
  const [nm, ...rest] = line.slice(1).split(/\s+/);
2819
2821
  const arg = rest.join(" ").trim();
2820
2822
  if (nm === "exit" || nm === "quit") {
@@ -3074,6 +3076,9 @@ program.action(async (opts) => {
3074
3076
  const near = nearest(nm, [...byName.keys()]);
3075
3077
  return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
3076
3078
  }
3079
+ // A message that begins with an absolute file path (the case skipped above) → inline it as an
3080
+ // @-mention so its content is read into the turn (drag-a-file-in → interpret it).
3081
+ line = inlineLeadingPath(line, existsSync);
3077
3082
  const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
3078
3083
  const appr = h.approval;
3079
3084
  // Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
@@ -3237,7 +3242,8 @@ program.action(async (opts) => {
3237
3242
  bar.renderBottom(); // bottom border + modes/usage
3238
3243
  if (!line)
3239
3244
  continue;
3240
- if (line.startsWith("/")) {
3245
+ // A dropped/pasted file path starts with '/' but isn't a command — read it, don't error (see TUI path).
3246
+ if (isSlashCommand(line)) {
3241
3247
  const [name, ...rest] = line.slice(1).split(/\s+/);
3242
3248
  const cmd = byName.get(name);
3243
3249
  if (!cmd) {
@@ -3275,6 +3281,7 @@ program.action(async (opts) => {
3275
3281
  break;
3276
3282
  continue;
3277
3283
  }
3284
+ line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
3278
3285
  const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
3279
3286
  recalledContext = "";
3280
3287
  history.push({ role: "user", content: userContent });
@@ -51,6 +51,21 @@ export function toOpenAI(system, history) {
51
51
  export function isReasoningModel(model) {
52
52
  return /^(o1|o3|o4|gpt-5)/i.test(model);
53
53
  }
54
+ /** DashScope (Alibaba — serves Qwen, GLM, …) runs a "thinking" phase that streams reasoning BEFORE the
55
+ * answer — the main latency there (measured: qwen3.7-plus ~14s thinking vs ~1.6s without). It can be
56
+ * silenced SERVER-SIDE with `enable_thinking:false` (actually stops generating the reasoning, not just
57
+ * hides it). So map hara's reasoning dial onto it: an explicit **off** → thinking off (fast);
58
+ * low/medium/high → on. Returns the flag to send, or `undefined` = leave the request UNTOUCHED — when
59
+ * the dial is UNSET (keep the model's own default; zero impact — the safe default the user asked for)
60
+ * or the endpoint isn't DashScope. Detected by the DashScope ENDPOINT (built-in qwen/qwen-oauth
61
+ * providers, OR a custom baseURL on dashscope — which is how the reporter's `custom:qwen3.7-plus`
62
+ * profile is set up), NOT the model name, so it covers custom Qwen/GLM profiles too. Exported for tests. */
63
+ export function dashscopeThinking(effort, baseURL, label) {
64
+ const isDashscope = /dashscope\.aliyuncs\.com/i.test(baseURL ?? "") || label === "qwen" || label === "qwen-oauth";
65
+ if (!isDashscope || effort === undefined)
66
+ return undefined;
67
+ return effort !== "off";
68
+ }
54
69
  /** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
55
70
  export function createOpenAIProvider(opts) {
56
71
  const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
@@ -78,6 +93,11 @@ export function createOpenAIProvider(opts) {
78
93
  if (opts.reasoningEffort && opts.reasoningEffort !== undefined && isReasoningModel(opts.model)) {
79
94
  params.reasoning_effort = opts.reasoningEffort === "off" ? "minimal" : opts.reasoningEffort;
80
95
  }
96
+ // DashScope: reasoning "off" REALLY disables the thinking phase (big speedup), not just hides it.
97
+ // Only fires on the DashScope endpoint + when the dial is set — UNSET stays untouched (zero impact).
98
+ const dsThink = dashscopeThinking(opts.reasoningEffort, opts.baseURL, opts.label);
99
+ if (dsThink !== undefined)
100
+ params.enable_thinking = dsThink;
81
101
  // Stream: emit text deltas live; accumulate tool-call args by index; grab usage from the tail chunk.
82
102
  let text = "";
83
103
  const acc = new Map();
package/dist/tui/App.js CHANGED
@@ -515,6 +515,11 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
515
515
  return askTextFn(question);
516
516
  };
517
517
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
518
+ // Enter the conversation flow INSTANTLY: yield one macrotask so ink paints the committed message +
519
+ // cleared input + spinner BEFORE the turn's synchronous prep runs (reading @-files, base64-encoding
520
+ // images) and before the model's slow first token. Without this, that sync prep blocks ink's flush,
521
+ // so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
522
+ await new Promise((resolve) => setTimeout(resolve, 0));
518
523
  try {
519
524
  await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
520
525
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.109.3",
3
+ "version": "0.109.5",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"