@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 +34 -0
- package/dist/context/mentions.js +17 -0
- package/dist/index.js +10 -3
- package/dist/providers/openai.js +20 -0
- package/dist/tui/App.js +5 -0
- package/package.json +1 -1
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
|
package/dist/context/mentions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/providers/openai.js
CHANGED
|
@@ -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
|
}
|