@nanhara/hara 0.120.0 → 0.121.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 +20 -0
- package/dist/context/mentions.js +17 -7
- package/dist/desk.js +64 -0
- package/dist/fs-read.js +157 -0
- package/dist/fs-write.js +105 -0
- package/dist/index.js +88 -0
- package/dist/tools/builtin.js +34 -11
- package/dist/tools/edit.js +10 -2
- package/dist/tools/patch.js +45 -15
- package/dist/tools/registry.js +20 -6
- package/dist/tools/result-limit.js +37 -0
- package/dist/tui/App.js +1 -1
- package/dist/tui/InputBox.js +62 -4
- package/dist/tui/input-history.js +167 -0
- package/dist/undo.js +6 -4
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
|
|
9
|
+
|
|
10
|
+
- **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
|
|
11
|
+
claim/complete/ack/cancel tasks, and persist the desk token with owner-only file permissions. The
|
|
12
|
+
client identifies itself as `hara-cli`, so mixed Codex/Claude Code/hara fleets remain legible.
|
|
13
|
+
- **Coding edits are transaction-safe.** `write_file` uses same-directory atomic replacement;
|
|
14
|
+
multi-file patches validate every destination before writing and roll back already-committed
|
|
15
|
+
files if a later commit fails. Undo snapshots are captured before mutation, failed writes do not
|
|
16
|
+
create false undo history, and symlink/non-regular destinations are rejected consistently.
|
|
17
|
+
- **Large files stay useful without flooding memory or context.** Slice reads stream only the
|
|
18
|
+
requested line window, `@file` mentions have explicit byte/line caps, and tool results are
|
|
19
|
+
bounded centrally (including plugin/MCP results) with clear truncation metadata.
|
|
20
|
+
- **Interactive input is friendlier and bounded.** The TUI composer has shell-style up/down history
|
|
21
|
+
with draft restoration, duplicate suppression, navigation reset after edits, and a fixed maximum
|
|
22
|
+
history size. The ask-mode sentinel is also source-safe on every supported Node runtime.
|
|
23
|
+
- **Faster cold start.** Unicode-width data and the write/edit tool graph are loaded only when the
|
|
24
|
+
active command needs them, reducing startup work for lightweight commands such as `--version`.
|
|
25
|
+
- **Production dependency hardening.** The Lark SDK's Axios transport is pinned to a patched 1.x
|
|
26
|
+
release instead of its vulnerable `~1.13.3` range; the production npm audit is clean at release.
|
|
27
|
+
|
|
8
28
|
## 0.120.0 — `hara feedback`: one door for humans and agents
|
|
9
29
|
|
|
10
30
|
- **`hara feedback "what happened"`** files a structured GitHub issue (repo hara-cli/hara,
|
package/dist/context/mentions.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @file mentions — expand `@path` references in user input into appended file contents,
|
|
2
2
|
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
6
6
|
import { fuzzyRank } from "../fuzzy.js";
|
|
7
7
|
import { mediaTypeFor } from "../images.js";
|
|
8
|
+
import { readTextPrefixSync } from "../fs-read.js";
|
|
8
9
|
const MAX_FILE = 50_000;
|
|
9
10
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
10
11
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
@@ -53,9 +54,10 @@ function expandRef(ref, cwd) {
|
|
|
53
54
|
// don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
|
|
54
55
|
if (mediaTypeFor(abs))
|
|
55
56
|
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
57
|
+
const prefix = readTextPrefixSync(abs, MAX_FILE);
|
|
58
|
+
if (prefix.binary)
|
|
59
|
+
return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
|
|
60
|
+
const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
|
|
59
61
|
return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
|
|
60
62
|
}
|
|
61
63
|
if (st.isDirectory()) {
|
|
@@ -73,16 +75,24 @@ function expandRef(ref, cwd) {
|
|
|
73
75
|
const cache = new Map();
|
|
74
76
|
const CACHE_MS = 5000;
|
|
75
77
|
function projectEntries(cwd) {
|
|
76
|
-
const
|
|
78
|
+
const key = resolve(cwd);
|
|
79
|
+
const hit = cache.get(key);
|
|
77
80
|
const now = Date.now();
|
|
78
81
|
if (hit && now - hit.at < CACHE_MS)
|
|
79
82
|
return hit.entries;
|
|
80
|
-
const files = listProjectFiles(
|
|
83
|
+
const files = listProjectFiles(key);
|
|
81
84
|
// files + their directory prefixes (so `@src/` drills into the subtree)
|
|
82
85
|
const entries = [...dirPrefixes(files), ...files];
|
|
83
|
-
cache.set(
|
|
86
|
+
cache.set(key, { at: now, entries });
|
|
84
87
|
return entries;
|
|
85
88
|
}
|
|
89
|
+
/** Invalidate the derived @path inventory after a coding tool creates/deletes a file. */
|
|
90
|
+
export function invalidateFileCandidates(cwd) {
|
|
91
|
+
if (cwd)
|
|
92
|
+
cache.delete(resolve(cwd));
|
|
93
|
+
else
|
|
94
|
+
cache.clear();
|
|
95
|
+
}
|
|
86
96
|
/**
|
|
87
97
|
* File/dir candidates whose path matches `query`, for @ autocomplete.
|
|
88
98
|
* Recurses subdirectories (git-tracked + untracked, or a filesystem walk outside git),
|
package/dist/desk.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `hara desk` client — how a hara agent connects to the hara-desk coordination server (the closed-
|
|
2
|
+
// source identity registry + task board). This is the OPEN-SOURCE side of the wire: a thin HTTP
|
|
3
|
+
// client + local credential storage. The desk itself (auth, board, L2 ack) lives in hara-desk.
|
|
4
|
+
//
|
|
5
|
+
// Credentials live in ~/.hara/desk.json (0600): the desk URL + this agent's token, written once at
|
|
6
|
+
// `hara desk register`. Every later call reads them back. The token is a bearer secret — never logged.
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const credsPath = () => join(homedir(), ".hara", "desk.json");
|
|
11
|
+
export function loadCreds() {
|
|
12
|
+
try {
|
|
13
|
+
const c = JSON.parse(readFileSync(credsPath(), "utf8"));
|
|
14
|
+
return c.url && c.token ? c : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function saveCreds(c) {
|
|
21
|
+
const dir = join(homedir(), ".hara");
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
const p = credsPath();
|
|
24
|
+
writeFileSync(p, JSON.stringify(c, null, 2));
|
|
25
|
+
try {
|
|
26
|
+
chmodSync(p, 0o600);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* best-effort on non-posix */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** One HTTP call to the desk. Auth via bearer token when creds are present. Throws on non-2xx with
|
|
33
|
+
* the server's error message (so the CLI can surface a real reason, not "request failed"). */
|
|
34
|
+
export async function deskCall(url, method, path, opts = {}) {
|
|
35
|
+
const r = await fetch(url.replace(/\/$/, "") + path, {
|
|
36
|
+
method,
|
|
37
|
+
headers: {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
|
|
40
|
+
},
|
|
41
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
42
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10000),
|
|
43
|
+
});
|
|
44
|
+
const text = await r.text();
|
|
45
|
+
let data = {};
|
|
46
|
+
try {
|
|
47
|
+
data = text ? JSON.parse(text) : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
data = { raw: text };
|
|
51
|
+
}
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
throw new Error(data?.error || `desk ${r.status}: ${text.slice(0, 200)}`);
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
/** Register this machine's agent with a desk and persist the returned credentials. `client`
|
|
57
|
+
* identifies the kind of client (defaults "hara-cli"); the desk is client-agnostic, so any agent
|
|
58
|
+
* may pass its own label. */
|
|
59
|
+
export async function registerAgent(url, enrollKey, name, owner, client = "hara-cli") {
|
|
60
|
+
const r = await deskCall(url, "POST", "/register", { body: { enrollKey, name, owner, client } });
|
|
61
|
+
const creds = { url, agentId: r.agentId, owner: r.owner, token: r.token };
|
|
62
|
+
saveCreds(creds);
|
|
63
|
+
return creds;
|
|
64
|
+
}
|
package/dist/fs-read.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Bounded streaming line reader for files too large to load as one string. It stores only the requested
|
|
2
|
+
// window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
|
|
3
|
+
import { closeSync, createReadStream, fstatSync, openSync, readSync } from "node:fs";
|
|
4
|
+
export class BinaryFileError extends Error {
|
|
5
|
+
constructor(path) {
|
|
6
|
+
super(`${path} appears to be binary (NUL byte detected)`);
|
|
7
|
+
this.name = "BinaryFileError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const DEFAULT_LINE_CAP = 2000;
|
|
11
|
+
const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
|
|
12
|
+
/** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
|
|
13
|
+
export function readTextPrefixSync(path, maxChars) {
|
|
14
|
+
const chars = Math.max(0, Math.floor(maxChars));
|
|
15
|
+
const fd = openSync(path, "r");
|
|
16
|
+
try {
|
|
17
|
+
const size = fstatSync(fd).size;
|
|
18
|
+
// Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
|
|
19
|
+
// `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
|
|
20
|
+
const byteLimit = Math.min(size, chars * 4 + 4);
|
|
21
|
+
const buffer = Buffer.allocUnsafe(byteLimit);
|
|
22
|
+
let read = 0;
|
|
23
|
+
while (read < byteLimit) {
|
|
24
|
+
const n = readSync(fd, buffer, read, byteLimit - read, read);
|
|
25
|
+
if (n === 0)
|
|
26
|
+
break;
|
|
27
|
+
read += n;
|
|
28
|
+
}
|
|
29
|
+
const bytes = buffer.subarray(0, read);
|
|
30
|
+
const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
|
|
31
|
+
const decoded = binary ? "" : bytes.toString("utf8");
|
|
32
|
+
return {
|
|
33
|
+
text: decoded.slice(0, chars),
|
|
34
|
+
truncated: size > read || decoded.length > chars,
|
|
35
|
+
binary,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
|
|
43
|
+
export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
|
|
44
|
+
const start = Math.max(1, Math.floor(offset));
|
|
45
|
+
const want = Math.max(1, Math.floor(limit));
|
|
46
|
+
const requestedEnd = start + want - 1;
|
|
47
|
+
const lineCap = Math.max(1, Math.floor(options.lineCap ?? DEFAULT_LINE_CAP));
|
|
48
|
+
const maxScan = Math.max(lineCap, Math.floor(options.maxScanChars ?? DEFAULT_MAX_SCAN));
|
|
49
|
+
const rendered = [];
|
|
50
|
+
let lineNo = 1;
|
|
51
|
+
let linePrefix = "";
|
|
52
|
+
let lineChars = 0;
|
|
53
|
+
let lineEndsWithCr = false;
|
|
54
|
+
let sawData = false;
|
|
55
|
+
let endedWithNewline = false;
|
|
56
|
+
let scanned = 0;
|
|
57
|
+
let hasMore = false;
|
|
58
|
+
let scanLimited = false;
|
|
59
|
+
let stoppedEarly = false;
|
|
60
|
+
const append = (part) => {
|
|
61
|
+
if (!part.length)
|
|
62
|
+
return;
|
|
63
|
+
sawData = true;
|
|
64
|
+
endedWithNewline = false;
|
|
65
|
+
if (lineNo > requestedEnd) {
|
|
66
|
+
hasMore = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (lineNo >= start) {
|
|
70
|
+
lineChars += part.length;
|
|
71
|
+
if (linePrefix.length < lineCap)
|
|
72
|
+
linePrefix += part.slice(0, lineCap - linePrefix.length);
|
|
73
|
+
lineEndsWithCr = part.endsWith("\r");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const finishLine = (partial = false) => {
|
|
77
|
+
const current = lineNo;
|
|
78
|
+
if (current >= start && current <= requestedEnd) {
|
|
79
|
+
let chars = lineChars;
|
|
80
|
+
let prefix = linePrefix;
|
|
81
|
+
if (!partial && lineEndsWithCr) {
|
|
82
|
+
chars = Math.max(0, chars - 1);
|
|
83
|
+
if (prefix.endsWith("\r"))
|
|
84
|
+
prefix = prefix.slice(0, -1);
|
|
85
|
+
}
|
|
86
|
+
const omitted = Math.max(0, chars - prefix.length);
|
|
87
|
+
const tail = partial
|
|
88
|
+
? `…[line continues; scan stopped after ${scanned} chars]`
|
|
89
|
+
: omitted
|
|
90
|
+
? `…[+${omitted} chars]`
|
|
91
|
+
: "";
|
|
92
|
+
rendered.push(`${String(current).padStart(6)}\t${prefix}${tail}`);
|
|
93
|
+
}
|
|
94
|
+
lineNo++;
|
|
95
|
+
linePrefix = "";
|
|
96
|
+
lineChars = 0;
|
|
97
|
+
lineEndsWithCr = false;
|
|
98
|
+
if (current > requestedEnd)
|
|
99
|
+
hasMore = true;
|
|
100
|
+
};
|
|
101
|
+
const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
|
|
102
|
+
try {
|
|
103
|
+
for await (const raw of stream) {
|
|
104
|
+
const chunk = String(raw);
|
|
105
|
+
if (chunk.length)
|
|
106
|
+
sawData = true;
|
|
107
|
+
if (scanned < 4096 && chunk.slice(0, 4096 - scanned).includes("\0"))
|
|
108
|
+
throw new BinaryFileError(path);
|
|
109
|
+
scanned += chunk.length;
|
|
110
|
+
let at = 0;
|
|
111
|
+
while (at < chunk.length) {
|
|
112
|
+
const newline = chunk.indexOf("\n", at);
|
|
113
|
+
if (newline < 0) {
|
|
114
|
+
append(chunk.slice(at));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
append(chunk.slice(at, newline));
|
|
118
|
+
finishLine();
|
|
119
|
+
endedWithNewline = true;
|
|
120
|
+
at = newline + 1;
|
|
121
|
+
if (hasMore)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
if (hasMore) {
|
|
125
|
+
stoppedEarly = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (scanned >= maxScan) {
|
|
129
|
+
scanLimited = true;
|
|
130
|
+
stoppedEarly = true;
|
|
131
|
+
if (lineNo >= start && lineNo <= requestedEnd)
|
|
132
|
+
finishLine(true);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
stream.destroy();
|
|
139
|
+
}
|
|
140
|
+
if (!stoppedEarly) {
|
|
141
|
+
// A trailing newline creates no phantom line, matching String#split + trailing-empty removal.
|
|
142
|
+
if (!endedWithNewline || !sawData)
|
|
143
|
+
finishLine();
|
|
144
|
+
const total = lineNo - 1;
|
|
145
|
+
if (start > total)
|
|
146
|
+
return `(file has ${total} lines — offset ${start} is past the end)`;
|
|
147
|
+
const end = start + rendered.length - 1;
|
|
148
|
+
const sliced = start > 1 || end < total;
|
|
149
|
+
const head = sliced ? `(lines ${start}–${end} of ${total}${end < total ? ` — continue with offset:${end + 1}` : ""})\n` : "";
|
|
150
|
+
return head + rendered.join("\n");
|
|
151
|
+
}
|
|
152
|
+
const end = start + Math.max(0, rendered.length - 1);
|
|
153
|
+
if (scanLimited) {
|
|
154
|
+
return `(large file scan stopped after ${scanned} chars — use grep or bash byte-range tools for a narrower target)\n${rendered.join("\n")}`;
|
|
155
|
+
}
|
|
156
|
+
return `(lines ${start}–${end}; more lines follow — continue with offset:${end + 1})\n${rendered.join("\n")}`;
|
|
157
|
+
}
|
package/dist/fs-write.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
|
|
2
|
+
// renamed into place so a killed process never leaves a half-written source file.
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { link, lstat, mkdir, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
5
|
+
export class FileChangedError extends Error {
|
|
6
|
+
code = "HARA_FILE_CHANGED";
|
|
7
|
+
constructor(path) {
|
|
8
|
+
super(`File changed while the edit was being prepared: ${path}. Re-read it and retry the edit.`);
|
|
9
|
+
this.name = "FileChangedError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
let tempSequence = 0;
|
|
13
|
+
async function writeTarget(path) {
|
|
14
|
+
try {
|
|
15
|
+
const info = await lstat(path);
|
|
16
|
+
// Replacing a symlink would silently break it. Stage beside the real target instead so edits retain
|
|
17
|
+
// the link (common in dotfile repos and generated workspace layouts).
|
|
18
|
+
if (info.isSymbolicLink())
|
|
19
|
+
return await realpath(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error?.code !== "ENOENT")
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
async function syncDirectory(path) {
|
|
28
|
+
// Directory fsync makes the rename durable across a power loss on POSIX. Some filesystems/platforms
|
|
29
|
+
// reject opening directories, so durability degrades gracefully after the file itself was synced.
|
|
30
|
+
try {
|
|
31
|
+
const handle = await open(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
await handle.sync();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await handle.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best effort */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
|
|
44
|
+
export async function atomicWriteText(path, content, options = {}) {
|
|
45
|
+
const target = await writeTarget(path);
|
|
46
|
+
const dir = dirname(target);
|
|
47
|
+
await mkdir(dir, { recursive: true });
|
|
48
|
+
let mode = 0o666;
|
|
49
|
+
try {
|
|
50
|
+
mode = (await stat(target)).mode & 0o777;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error?.code !== "ENOENT")
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
// Keep the staging basename fixed-size: prefixing the destination's full basename would make a
|
|
57
|
+
// perfectly valid near-NAME_MAX file impossible to edit because the temporary name becomes longer.
|
|
58
|
+
const temp = join(dir, `.hara-${process.pid}-${Date.now().toString(36)}-${tempSequence++}.tmp`);
|
|
59
|
+
let staged = false;
|
|
60
|
+
try {
|
|
61
|
+
const handle = await open(temp, "wx", mode);
|
|
62
|
+
staged = true;
|
|
63
|
+
try {
|
|
64
|
+
await handle.writeFile(content, "utf8");
|
|
65
|
+
await handle.sync();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await handle.close();
|
|
69
|
+
}
|
|
70
|
+
if (options.expected === null) {
|
|
71
|
+
// link(2) is an atomic create-if-absent operation. A plain rename would overwrite a file that
|
|
72
|
+
// appeared after validation, defeating create's no-clobber contract.
|
|
73
|
+
try {
|
|
74
|
+
await link(temp, target);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error?.code === "EEXIST")
|
|
78
|
+
throw new FileChangedError(path);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
await unlink(temp);
|
|
82
|
+
staged = false;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (typeof options.expected === "string") {
|
|
86
|
+
let current;
|
|
87
|
+
try {
|
|
88
|
+
current = await readFile(target, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new FileChangedError(path);
|
|
92
|
+
}
|
|
93
|
+
if (current !== options.expected)
|
|
94
|
+
throw new FileChangedError(path);
|
|
95
|
+
}
|
|
96
|
+
await rename(temp, target);
|
|
97
|
+
staged = false;
|
|
98
|
+
}
|
|
99
|
+
await syncDirectory(dir);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (staged)
|
|
103
|
+
await unlink(temp).catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2036,6 +2036,94 @@ program
|
|
|
2036
2036
|
out(`\n(gh CLI unavailable or filing failed — copy everything below into ${NEW_ISSUE_URL})\n\n# ${issueTitle(desc)}\n\n${body}\n`);
|
|
2037
2037
|
}
|
|
2038
2038
|
});
|
|
2039
|
+
// `hara desk` — connect to a hara-desk coordination server (identity registry + task board).
|
|
2040
|
+
// The desk is the closed-source enterprise piece; this is the open-source client side.
|
|
2041
|
+
const deskCmd = program.command("desk").description("coordinate with a hara-desk server (register · post · board · claim · complete)");
|
|
2042
|
+
deskCmd
|
|
2043
|
+
.command("register")
|
|
2044
|
+
.description("register this agent with a desk and save credentials to ~/.hara/desk.json")
|
|
2045
|
+
.requiredOption("--url <url>", "desk base URL (e.g. http://127.0.0.1:4200)")
|
|
2046
|
+
.requiredOption("--key <enrollKey>", "the desk's enroll key")
|
|
2047
|
+
.option("--name <name>", "agent name shown in the registry", "hara-cli")
|
|
2048
|
+
.option("--owner <owner>", "the human this agent belongs to", "me")
|
|
2049
|
+
.action(async (o) => {
|
|
2050
|
+
const { registerAgent } = await import("./desk.js");
|
|
2051
|
+
try {
|
|
2052
|
+
const creds = await registerAgent(o.url, o.key, o.name, o.owner);
|
|
2053
|
+
out(c.green("✓ registered ") + `${creds.agentId} (owner ${creds.owner}) → ${creds.url}\n`);
|
|
2054
|
+
}
|
|
2055
|
+
catch (e) {
|
|
2056
|
+
out(c.red(`register failed: ${e.message}\n`));
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
deskCmd
|
|
2060
|
+
.command("post [title...]")
|
|
2061
|
+
.description("post a task or feedback report to the board")
|
|
2062
|
+
.option("--dispatch", "a dispatch task (someone does work) instead of a feedback report")
|
|
2063
|
+
.option("--high", "high-risk dispatch (needs an owner ack before it can complete)")
|
|
2064
|
+
.option("--body <text>", "task body / details", "")
|
|
2065
|
+
.action(async (parts, o) => {
|
|
2066
|
+
const { loadCreds, deskCall } = await import("./desk.js");
|
|
2067
|
+
const creds = loadCreds();
|
|
2068
|
+
if (!creds)
|
|
2069
|
+
return void out(c.red("not registered — run `hara desk register --url … --key …` first\n"));
|
|
2070
|
+
const title = (parts ?? []).join(" ").trim();
|
|
2071
|
+
if (!title)
|
|
2072
|
+
return void out("Usage: hara desk post <title> [--dispatch] [--high] [--body …]\n");
|
|
2073
|
+
try {
|
|
2074
|
+
const r = await deskCall(creds.url, "POST", "/tasks", { token: creds.token, body: { kind: o.dispatch ? "dispatch" : "feedback", risk: o.high ? "high" : "low", title, body: o.body } });
|
|
2075
|
+
out(c.green("✓ posted ") + `${r.task.id} (${r.task.kind}/${r.task.state})\n`);
|
|
2076
|
+
}
|
|
2077
|
+
catch (e) {
|
|
2078
|
+
out(c.red(`post failed: ${e.message}\n`));
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
deskCmd
|
|
2082
|
+
.command("board")
|
|
2083
|
+
.description("list the board (default: open tasks)")
|
|
2084
|
+
.option("--state <state>", "open | claimed | done | cancelled", "open")
|
|
2085
|
+
.option("--kind <kind>", "feedback | dispatch")
|
|
2086
|
+
.action(async (o) => {
|
|
2087
|
+
const { loadCreds, deskCall } = await import("./desk.js");
|
|
2088
|
+
const creds = loadCreds();
|
|
2089
|
+
if (!creds)
|
|
2090
|
+
return void out(c.red("not registered — run `hara desk register` first\n"));
|
|
2091
|
+
try {
|
|
2092
|
+
const q = `/tasks?state=${encodeURIComponent(o.state)}${o.kind ? `&kind=${encodeURIComponent(o.kind)}` : ""}`;
|
|
2093
|
+
const r = await deskCall(creds.url, "GET", q, { token: creds.token });
|
|
2094
|
+
if (!r.tasks.length)
|
|
2095
|
+
return void out(c.dim("(board empty)\n"));
|
|
2096
|
+
for (const t of r.tasks)
|
|
2097
|
+
out(`${t.id} ${c.bold(t.kind)}${t.risk === "high" ? c.red("!") : " "} ${t.state.padEnd(8)} ${t.title}\n`);
|
|
2098
|
+
}
|
|
2099
|
+
catch (e) {
|
|
2100
|
+
out(c.red(`board failed: ${e.message}\n`));
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
for (const [verb, path, ok] of [
|
|
2104
|
+
["claim", "claim", "claimed"],
|
|
2105
|
+
["complete", "complete", "completed"],
|
|
2106
|
+
["ack", "ack", "acked"],
|
|
2107
|
+
["cancel", "cancel", "cancelled"],
|
|
2108
|
+
]) {
|
|
2109
|
+
deskCmd
|
|
2110
|
+
.command(`${verb} <taskId>`)
|
|
2111
|
+
.description(`${verb} a task`)
|
|
2112
|
+
.option("--detail <text>", "note (complete)", "")
|
|
2113
|
+
.action(async (taskId, o) => {
|
|
2114
|
+
const { loadCreds, deskCall } = await import("./desk.js");
|
|
2115
|
+
const creds = loadCreds();
|
|
2116
|
+
if (!creds)
|
|
2117
|
+
return void out(c.red("not registered — run `hara desk register` first\n"));
|
|
2118
|
+
try {
|
|
2119
|
+
const r = await deskCall(creds.url, "POST", `/tasks/${taskId}/${path}`, { token: creds.token, body: verb === "complete" ? { detail: o.detail } : {} });
|
|
2120
|
+
out(c.green(`✓ ${ok} `) + `${r.task?.id ?? taskId}${r.task ? ` (${r.task.state})` : ""}\n`);
|
|
2121
|
+
}
|
|
2122
|
+
catch (e) {
|
|
2123
|
+
out(c.red(`${verb} failed: ${e.message}\n`));
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2039
2127
|
const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
|
|
2040
2128
|
skillsCmd
|
|
2041
2129
|
.command("init")
|
package/dist/tools/builtin.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { readFile,
|
|
2
|
-
import {
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve, isAbsolute } from "node:path";
|
|
3
3
|
import { stdout as procOut } from "node:process";
|
|
4
4
|
import { registerTool } from "./registry.js";
|
|
5
5
|
import { runShell } from "../sandbox.js";
|
|
6
|
-
import { nearestPaths } from "../fs-walk.js";
|
|
6
|
+
import { isProbablyBinary, nearestPaths } from "../fs-walk.js";
|
|
7
7
|
import { emitDiff } from "../diff.js";
|
|
8
8
|
import { recordEdit } from "../undo.js";
|
|
9
|
+
import { atomicWriteText } from "../fs-write.js";
|
|
10
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
11
|
+
import { BinaryFileError, streamFileSlice } from "../fs-read.js";
|
|
9
12
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
10
13
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
11
14
|
const MAX = 100_000;
|
|
@@ -43,8 +46,9 @@ export function capHeadTail(s, max = MAX) {
|
|
|
43
46
|
const head = Math.floor(max * 0.6);
|
|
44
47
|
return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
|
|
45
48
|
}
|
|
46
|
-
const READ_LINES =
|
|
49
|
+
const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
|
|
47
50
|
const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
|
|
51
|
+
const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
|
|
48
52
|
/** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
|
|
49
53
|
* tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
|
|
50
54
|
* re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
|
|
@@ -71,22 +75,31 @@ export function renderFileSlice(text, offset, limit) {
|
|
|
71
75
|
}
|
|
72
76
|
registerTool({
|
|
73
77
|
name: "read_file",
|
|
74
|
-
description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to
|
|
78
|
+
description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 300 lines by default — for a longer file pass offset/limit to read the next slice (the header tells you where to continue). Large files are streamed instead of loaded whole. Prefer grep to locate, then read just that region.",
|
|
75
79
|
input_schema: {
|
|
76
80
|
type: "object",
|
|
77
81
|
properties: {
|
|
78
82
|
path: { type: "string", description: "File path, relative to cwd or absolute" },
|
|
79
83
|
offset: { type: "number", description: "1-based line number to start from (for long files)" },
|
|
80
|
-
limit: { type: "number", description: "max lines to return (default
|
|
84
|
+
limit: { type: "number", description: "max lines to return (default 300)" },
|
|
81
85
|
},
|
|
82
86
|
required: ["path"],
|
|
83
87
|
},
|
|
84
88
|
kind: "read",
|
|
85
89
|
async run(input, ctx) {
|
|
90
|
+
const p = abs(input.path, ctx.cwd);
|
|
86
91
|
try {
|
|
87
|
-
|
|
92
|
+
const info = await stat(p);
|
|
93
|
+
if (info.size > BUFFERED_READ_BYTES)
|
|
94
|
+
return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
|
|
95
|
+
const buf = await readFile(p);
|
|
96
|
+
if (isProbablyBinary(buf))
|
|
97
|
+
throw new BinaryFileError(p);
|
|
98
|
+
return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
|
|
88
99
|
}
|
|
89
100
|
catch (e) {
|
|
101
|
+
if (e instanceof BinaryFileError)
|
|
102
|
+
return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
|
|
90
103
|
const near = nearestPaths(ctx.cwd, input.path);
|
|
91
104
|
return `Error: cannot read ${input.path}: ${e.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
92
105
|
}
|
|
@@ -106,17 +119,27 @@ registerTool({
|
|
|
106
119
|
kind: "edit",
|
|
107
120
|
async run(input, ctx) {
|
|
108
121
|
const p = abs(input.path, ctx.cwd);
|
|
122
|
+
if (typeof input.content !== "string")
|
|
123
|
+
return "Error: write_file `content` must be a string. No changes written.";
|
|
109
124
|
let prev = null;
|
|
110
125
|
try {
|
|
111
126
|
prev = await readFile(p, "utf8");
|
|
112
127
|
}
|
|
113
|
-
catch {
|
|
114
|
-
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error?.code !== "ENOENT")
|
|
130
|
+
return `Error: cannot inspect ${input.path}: ${error?.code ?? error?.message}. No changes written.`;
|
|
131
|
+
}
|
|
132
|
+
if (prev === input.content)
|
|
133
|
+
return `Unchanged ${p} (${input.content.length} chars already match).`;
|
|
134
|
+
try {
|
|
135
|
+
await atomicWriteText(p, input.content, { expected: prev });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
115
139
|
}
|
|
116
|
-
await mkdir(dirname(p), { recursive: true });
|
|
117
|
-
await writeFile(p, input.content, "utf8");
|
|
118
140
|
emitDiff(input.path, prev ?? "", input.content, ctx.ui);
|
|
119
141
|
recordEdit([{ path: input.path, absPath: p, before: prev }]);
|
|
142
|
+
invalidateFileCandidates(ctx.cwd);
|
|
120
143
|
return `Wrote ${String(input.content).length} chars to ${p}`;
|
|
121
144
|
},
|
|
122
145
|
});
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { registerTool } from "./registry.js";
|
|
4
4
|
import { nearestPaths } from "../fs-walk.js";
|
|
5
5
|
import { emitDiff } from "../diff.js";
|
|
6
6
|
import { applyEdits } from "./apply-core.js";
|
|
7
7
|
import { recordEdit } from "../undo.js";
|
|
8
|
+
import { atomicWriteText } from "../fs-write.js";
|
|
9
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
8
10
|
registerTool({
|
|
9
11
|
name: "edit_file",
|
|
10
12
|
description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
|
|
@@ -53,9 +55,15 @@ registerTool({
|
|
|
53
55
|
const res = applyEdits(text, edits);
|
|
54
56
|
if ("error" in res)
|
|
55
57
|
return `Error: ${res.error} in ${input.path}. No changes written.`;
|
|
56
|
-
|
|
58
|
+
try {
|
|
59
|
+
await atomicWriteText(p, res.text, { expected: text });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
63
|
+
}
|
|
57
64
|
emitDiff(input.path, text, res.text, ctx.ui);
|
|
58
65
|
recordEdit([{ path: input.path, absPath: p, before: text }]);
|
|
66
|
+
invalidateFileCandidates(ctx.cwd);
|
|
59
67
|
const note = res.fuzzy ? " (quote-normalized)" : "";
|
|
60
68
|
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
61
69
|
return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;
|
package/dist/tools/patch.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
|
|
2
2
|
// computed in memory first; nothing is written unless every change applies cleanly.
|
|
3
|
-
import {
|
|
4
|
-
import { isAbsolute, resolve
|
|
3
|
+
import { lstat, readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { applyEdits } from "./apply-core.js";
|
|
7
7
|
import { emitDiff } from "../diff.js";
|
|
8
8
|
import { recordEdit } from "../undo.js";
|
|
9
|
+
import { atomicWriteText, FileChangedError } from "../fs-write.js";
|
|
10
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
9
11
|
registerTool({
|
|
10
12
|
name: "apply_patch",
|
|
11
13
|
description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
|
|
@@ -51,13 +53,29 @@ registerTool({
|
|
|
51
53
|
const abs = (pth) => (isAbsolute(pth) ? pth : resolve(ctx.cwd, pth));
|
|
52
54
|
// PHASE 1 — validate + compute every change in memory; bail before writing anything.
|
|
53
55
|
const plans = [];
|
|
56
|
+
const plannedPaths = new Set();
|
|
54
57
|
for (let i = 0; i < changes.length; i++) {
|
|
55
58
|
const ch = changes[i];
|
|
56
59
|
const tag = `change ${i + 1}/${changes.length}`;
|
|
57
60
|
if (typeof ch.path !== "string" || !ch.path)
|
|
58
61
|
return `Error: ${tag} is missing a path. Nothing written.`;
|
|
59
62
|
const p = abs(ch.path);
|
|
60
|
-
|
|
63
|
+
if (plannedPaths.has(p))
|
|
64
|
+
return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
|
|
65
|
+
plannedPaths.add(p);
|
|
66
|
+
let type = ch.type ?? (ch.edits ? "update" : "create");
|
|
67
|
+
// Backward-compatible shorthand: {path, content} updates an existing file and creates a missing
|
|
68
|
+
// one. An EXPLICIT type:create is stricter and never clobbers an existing path.
|
|
69
|
+
if (!ch.type && !ch.edits) {
|
|
70
|
+
try {
|
|
71
|
+
await lstat(p);
|
|
72
|
+
type = "update";
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error?.code !== "ENOENT")
|
|
76
|
+
return `Error: ${tag} cannot inspect ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
61
79
|
if (type === "delete") {
|
|
62
80
|
let before;
|
|
63
81
|
try {
|
|
@@ -71,16 +89,15 @@ registerTool({
|
|
|
71
89
|
else if (type === "create") {
|
|
72
90
|
if (typeof ch.content !== "string")
|
|
73
91
|
return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
|
|
74
|
-
let before = "";
|
|
75
|
-
let existed = false;
|
|
76
92
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
await lstat(p);
|
|
94
|
+
return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
|
|
79
95
|
}
|
|
80
|
-
catch {
|
|
81
|
-
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error?.code !== "ENOENT")
|
|
98
|
+
return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
82
99
|
}
|
|
83
|
-
plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed });
|
|
100
|
+
plans.push({ path: ch.path, abs: p, type, before: "", after: ch.content, existed: false });
|
|
84
101
|
}
|
|
85
102
|
else {
|
|
86
103
|
// update
|
|
@@ -108,27 +125,39 @@ registerTool({
|
|
|
108
125
|
try {
|
|
109
126
|
for (const pl of plans) {
|
|
110
127
|
if (pl.type === "delete") {
|
|
128
|
+
let current;
|
|
129
|
+
try {
|
|
130
|
+
current = await readFile(pl.abs, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new FileChangedError(pl.path);
|
|
134
|
+
}
|
|
135
|
+
if (current !== pl.before)
|
|
136
|
+
throw new FileChangedError(pl.path);
|
|
111
137
|
await unlink(pl.abs);
|
|
112
138
|
}
|
|
113
139
|
else {
|
|
114
|
-
await
|
|
115
|
-
await writeFile(pl.abs, pl.after, "utf8");
|
|
140
|
+
await atomicWriteText(pl.abs, pl.after, { expected: pl.existed ? pl.before : null });
|
|
116
141
|
}
|
|
117
142
|
applied.push(pl);
|
|
118
143
|
}
|
|
119
144
|
}
|
|
120
145
|
catch (e) {
|
|
146
|
+
const rollbackFailures = [];
|
|
121
147
|
for (const pl of applied.reverse()) {
|
|
122
148
|
try {
|
|
123
149
|
if (pl.type === "create" && !pl.existed)
|
|
124
150
|
await unlink(pl.abs); // remove a file we created
|
|
125
151
|
else
|
|
126
|
-
await
|
|
152
|
+
await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
|
|
127
153
|
}
|
|
128
|
-
catch {
|
|
129
|
-
|
|
154
|
+
catch (rollbackError) {
|
|
155
|
+
rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
130
156
|
}
|
|
131
157
|
}
|
|
158
|
+
if (rollbackFailures.length) {
|
|
159
|
+
return `Error: apply_patch failed (${e instanceof Error ? e.message : String(e)}); rollback was INCOMPLETE: ${rollbackFailures.join("; ")}. Inspect these files before continuing.`;
|
|
160
|
+
}
|
|
132
161
|
return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
|
|
133
162
|
}
|
|
134
163
|
// All writes succeeded → now show diffs + record the undo snapshot.
|
|
@@ -137,6 +166,7 @@ registerTool({
|
|
|
137
166
|
return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
|
|
138
167
|
});
|
|
139
168
|
recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
|
|
169
|
+
invalidateFileCandidates(ctx.cwd);
|
|
140
170
|
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
|
|
141
171
|
},
|
|
142
172
|
});
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { limitToolResult } from "./result-limit.js";
|
|
1
2
|
/** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
|
|
2
3
|
* against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
|
|
3
4
|
* path/content mid-stream) — the loop rejects the call with a precise error instead of executing
|
|
@@ -9,8 +10,16 @@ export function missingRequired(tool, input) {
|
|
|
9
10
|
return req.filter((k) => obj[k] === undefined || obj[k] === null);
|
|
10
11
|
}
|
|
11
12
|
const registry = new Map();
|
|
13
|
+
let specsCache = null;
|
|
12
14
|
export function registerTool(t) {
|
|
13
|
-
|
|
15
|
+
const run = t.run;
|
|
16
|
+
registry.set(t.name, {
|
|
17
|
+
...t,
|
|
18
|
+
// Apply the context boundary at registration so every caller (main loop, tests, embedders) gets
|
|
19
|
+
// identical behavior instead of relying on one orchestration path to remember the cap.
|
|
20
|
+
run: async (input, ctx) => limitToolResult(await run(input, ctx)),
|
|
21
|
+
});
|
|
22
|
+
specsCache = null;
|
|
14
23
|
}
|
|
15
24
|
export function getTool(name) {
|
|
16
25
|
return registry.get(name);
|
|
@@ -20,9 +29,14 @@ export function getTools() {
|
|
|
20
29
|
}
|
|
21
30
|
/** Provider-neutral tool specs derived from the registry. */
|
|
22
31
|
export function toolSpecs() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
if (!specsCache) {
|
|
33
|
+
specsCache = getTools().map((t) => ({
|
|
34
|
+
name: t.name,
|
|
35
|
+
description: t.description,
|
|
36
|
+
input_schema: t.input_schema,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
// Callers commonly filter the array for a role. Return a shallow copy so that never mutates the
|
|
40
|
+
// stable cached snapshot shared by subsequent agent rounds.
|
|
41
|
+
return specsCache.slice();
|
|
28
42
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One invariant for every registered tool: no single result may monopolize the model context. Individual
|
|
2
|
+
// tools can use tighter domain-specific limits, but this final boundary also covers plugins/new tools.
|
|
3
|
+
export const MAX_TOOL_RESULT_CHARS = 24_000;
|
|
4
|
+
function safeHead(value, end) {
|
|
5
|
+
let at = Math.max(0, Math.min(value.length, end));
|
|
6
|
+
if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
|
|
7
|
+
at--;
|
|
8
|
+
return value.slice(0, at);
|
|
9
|
+
}
|
|
10
|
+
function safeTail(value, start) {
|
|
11
|
+
let at = Math.max(0, Math.min(value.length, start));
|
|
12
|
+
if (at < value.length && /[\uDC00-\uDFFF]/.test(value[at] ?? ""))
|
|
13
|
+
at++;
|
|
14
|
+
return value.slice(at);
|
|
15
|
+
}
|
|
16
|
+
/** Keep actionable beginnings and endings while bounding the exact string persisted in history. */
|
|
17
|
+
export function limitToolResult(value, max = MAX_TOOL_RESULT_CHARS) {
|
|
18
|
+
const text = typeof value === "string" ? value : String(value ?? "");
|
|
19
|
+
const cap = Math.max(0, Math.floor(max));
|
|
20
|
+
if (text.length <= cap)
|
|
21
|
+
return text;
|
|
22
|
+
if (cap === 0)
|
|
23
|
+
return "";
|
|
24
|
+
let omitted = text.length - cap;
|
|
25
|
+
let fullMarker = "";
|
|
26
|
+
// The marker consumes part of the budget too. Iterate twice so its count reflects the actual payload
|
|
27
|
+
// removed rather than understating it by roughly the marker's own length.
|
|
28
|
+
for (let i = 0; i < 2; i++) {
|
|
29
|
+
fullMarker = `\n…[hara: ${omitted} chars omitted; narrow the query or continue read_file with offset/limit]…\n`;
|
|
30
|
+
omitted = text.length - Math.max(0, cap - fullMarker.length);
|
|
31
|
+
}
|
|
32
|
+
const marker = fullMarker.length < cap ? fullMarker : "…[truncated]…".slice(0, cap);
|
|
33
|
+
const room = cap - marker.length;
|
|
34
|
+
const headChars = Math.floor(room * 0.6);
|
|
35
|
+
const tailChars = room - headChars;
|
|
36
|
+
return safeHead(text, headChars) + marker + safeTail(text, text.length - tailChars);
|
|
37
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -516,7 +516,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
516
516
|
const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
|
|
517
517
|
// ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
|
|
518
518
|
// when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
|
|
519
|
-
const OTHER = "
|
|
519
|
+
const OTHER = "\\0__ask_other__"; // escaped sentinel keeps this TypeScript file text-only
|
|
520
520
|
const askFn = async (question, options) => {
|
|
521
521
|
if (options && options.length) {
|
|
522
522
|
const choice = await openPrompt(question, [
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -14,10 +14,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
14
|
// of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
|
|
15
15
|
// "› " prompt gutter and reflows unpredictably as you type.
|
|
16
16
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
17
|
-
import { memo, useMemo, useState } from "react";
|
|
17
|
+
import { memo, useMemo, useRef, useState } from "react";
|
|
18
18
|
import { fileCandidates } from "../context/mentions.js";
|
|
19
19
|
import { imagePathFromPaste } from "../images.js";
|
|
20
20
|
import { vimNormal } from "./vim.js";
|
|
21
|
+
import { ComposerHistory, moveCursorLine, nextGraphemeIndex, previousGraphemeIndex, previousWordIndex, } from "./input-history.js";
|
|
21
22
|
export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
|
|
22
23
|
export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
|
|
23
24
|
const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
@@ -312,15 +313,30 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
312
313
|
const [mode, setMode] = useState("insert"); // vim only
|
|
313
314
|
const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
|
|
314
315
|
const [register, setRegister] = useState(""); // vim yank/delete register
|
|
316
|
+
const historyRef = useRef(null);
|
|
317
|
+
if (!historyRef.current)
|
|
318
|
+
historyRef.current = new ComposerHistory();
|
|
319
|
+
const inputHistory = historyRef.current;
|
|
315
320
|
const set = (v, c) => {
|
|
321
|
+
inputHistory.abandonNavigation();
|
|
316
322
|
setValue(v);
|
|
317
323
|
setCursor(c);
|
|
318
324
|
setSel(0);
|
|
319
325
|
setDismissed(false);
|
|
320
326
|
};
|
|
327
|
+
const snapshot = () => ({ value, attachments: images, pastes });
|
|
328
|
+
const restore = (draft) => {
|
|
329
|
+
setValue(draft.value);
|
|
330
|
+
setCursor(draft.value.length);
|
|
331
|
+
setImages(draft.attachments);
|
|
332
|
+
setPastes(draft.pastes);
|
|
333
|
+
setSel(0);
|
|
334
|
+
setDismissed(false);
|
|
335
|
+
};
|
|
321
336
|
// Attach an image: drop a highlighted `[Image #N]` token inline at the cursor and track the file
|
|
322
337
|
// (codex / Claude-Code style). Backspace over the token removes both.
|
|
323
338
|
const addImage = (img) => {
|
|
339
|
+
inputHistory.abandonNavigation();
|
|
324
340
|
const tok = `[Image #${images.length + 1}]`;
|
|
325
341
|
const before = value.slice(0, cursor);
|
|
326
342
|
const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
|
|
@@ -334,6 +350,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
334
350
|
// the box: typing stays smooth (the VALUE stays short), the box stays small, and a multi-line paste
|
|
335
351
|
// can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
|
|
336
352
|
const addPaste = (text) => {
|
|
353
|
+
inputHistory.abandonNavigation();
|
|
337
354
|
const lines = text.split("\n").length;
|
|
338
355
|
const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
|
|
339
356
|
const before = value.slice(0, cursor);
|
|
@@ -348,6 +365,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
348
365
|
const submit = (text) => {
|
|
349
366
|
if (!text.trim() && images.length === 0)
|
|
350
367
|
return; // nothing to send
|
|
368
|
+
inputHistory.record(snapshot());
|
|
351
369
|
onSubmit?.(expandPastes(text), images.length ? images : undefined);
|
|
352
370
|
set("", 0);
|
|
353
371
|
setImages([]);
|
|
@@ -365,6 +383,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
365
383
|
const complete = (cand) => {
|
|
366
384
|
if (!mention)
|
|
367
385
|
return;
|
|
386
|
+
inputHistory.abandonNavigation();
|
|
368
387
|
const before = value.slice(0, mention.start); // includes the leading '@'
|
|
369
388
|
const after = value.slice(cursor);
|
|
370
389
|
const insert = cand.endsWith("/") ? cand : cand + " "; // dirs keep drilling; files end the mention
|
|
@@ -406,6 +425,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
406
425
|
return setCursor((c) => Math.max(0, c - 1));
|
|
407
426
|
if (input && !key.ctrl && !key.meta) {
|
|
408
427
|
const st = vimNormal({ value, cursor, mode, pending, register }, input);
|
|
428
|
+
if (st.value !== value)
|
|
429
|
+
inputHistory.abandonNavigation();
|
|
409
430
|
setValue(st.value);
|
|
410
431
|
setCursor(st.cursor);
|
|
411
432
|
setMode(st.mode);
|
|
@@ -416,20 +437,54 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
416
437
|
}
|
|
417
438
|
return;
|
|
418
439
|
}
|
|
440
|
+
if (key.upArrow) {
|
|
441
|
+
const onFirstLine = cursor === 0 || value.lastIndexOf("\n", cursor - 1) < 0;
|
|
442
|
+
if (onFirstLine) {
|
|
443
|
+
const draft = inputHistory.older(snapshot());
|
|
444
|
+
if (draft)
|
|
445
|
+
restore(draft);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
setCursor(moveCursorLine(value, cursor, -1));
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (key.downArrow) {
|
|
453
|
+
const onLastLine = value.indexOf("\n", cursor) < 0;
|
|
454
|
+
if (onLastLine) {
|
|
455
|
+
const draft = inputHistory.newer();
|
|
456
|
+
if (draft)
|
|
457
|
+
restore(draft);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
setCursor(moveCursorLine(value, cursor, 1));
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
419
464
|
if (key.return) {
|
|
465
|
+
if (key.shift || key.meta) {
|
|
466
|
+
set(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
420
469
|
submit(value);
|
|
421
470
|
return;
|
|
422
471
|
}
|
|
423
472
|
if (key.leftArrow)
|
|
424
|
-
return setCursor((c) =>
|
|
473
|
+
return setCursor((c) => previousGraphemeIndex(value, c));
|
|
425
474
|
if (key.rightArrow)
|
|
426
|
-
return setCursor((c) =>
|
|
475
|
+
return setCursor((c) => nextGraphemeIndex(value, c));
|
|
427
476
|
if (key.ctrl && input === "a")
|
|
428
477
|
return setCursor(0);
|
|
429
478
|
if (key.ctrl && input === "e")
|
|
430
479
|
return setCursor(value.length);
|
|
431
480
|
if (key.ctrl && input === "u")
|
|
432
481
|
return set(value.slice(cursor), 0);
|
|
482
|
+
if (key.ctrl && input === "w") {
|
|
483
|
+
const start = previousWordIndex(value, cursor);
|
|
484
|
+
return set(value.slice(0, start) + value.slice(cursor), start);
|
|
485
|
+
}
|
|
486
|
+
if (key.ctrl && input === "k")
|
|
487
|
+
return set(value.slice(0, cursor), cursor);
|
|
433
488
|
if (key.ctrl && input === "v") {
|
|
434
489
|
// paste a screenshot / image from the OS clipboard
|
|
435
490
|
const img = onClipboardImage?.();
|
|
@@ -442,6 +497,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
442
497
|
const head = value.slice(0, cursor);
|
|
443
498
|
const pm = /\[Paste #(\d+) \+\d+ lines\]\s?$/.exec(head); // paste token deletes whole + renumbers
|
|
444
499
|
if (pm) {
|
|
500
|
+
inputHistory.abandonNavigation();
|
|
445
501
|
const n = Number(pm[1]);
|
|
446
502
|
const kept = head.slice(0, pm.index) + value.slice(cursor);
|
|
447
503
|
const renumbered = kept.replace(/\[Paste #(\d+)( \+\d+ lines\])/g, (m2, d, tail) => (Number(d) > n ? `[Paste #${Number(d) - 1}${tail}` : m2));
|
|
@@ -454,6 +510,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
454
510
|
}
|
|
455
511
|
const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
|
|
456
512
|
if (tm) {
|
|
513
|
+
inputHistory.abandonNavigation();
|
|
457
514
|
const n = Number(tm[1]);
|
|
458
515
|
const kept = head.slice(0, tm.index) + value.slice(cursor);
|
|
459
516
|
const renumbered = kept.replace(/\[Image #(\d+)\]/g, (m2, d) => (Number(d) > n ? `[Image #${Number(d) - 1}]` : m2));
|
|
@@ -464,7 +521,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
464
521
|
setDismissed(false);
|
|
465
522
|
return;
|
|
466
523
|
}
|
|
467
|
-
|
|
524
|
+
const previous = previousGraphemeIndex(value, cursor);
|
|
525
|
+
set(value.slice(0, previous) + value.slice(cursor), previous);
|
|
468
526
|
}
|
|
469
527
|
return;
|
|
470
528
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Pure composer state: bounded shell-style history plus Unicode-safe cursor helpers. Keeping this out
|
|
2
|
+
// of the Ink component makes navigation deterministic and avoids copying a growing history on each key.
|
|
3
|
+
const cloneDraft = (draft) => ({
|
|
4
|
+
value: draft.value,
|
|
5
|
+
attachments: [...draft.attachments],
|
|
6
|
+
pastes: [...draft.pastes],
|
|
7
|
+
});
|
|
8
|
+
const draftChars = (draft) => draft.value.length + draft.pastes.reduce((sum, paste) => sum + paste.length, 0) + draft.attachments.length * 128;
|
|
9
|
+
/** Bounded in-process history. The draft that existed before Up is restored after navigating Down. */
|
|
10
|
+
export class ComposerHistory {
|
|
11
|
+
maxEntries;
|
|
12
|
+
maxChars;
|
|
13
|
+
entries = [];
|
|
14
|
+
cursor = null;
|
|
15
|
+
scratch = null;
|
|
16
|
+
totalChars = 0;
|
|
17
|
+
constructor(maxEntries = 100, maxChars = 2_000_000) {
|
|
18
|
+
this.maxEntries = maxEntries;
|
|
19
|
+
this.maxChars = maxChars;
|
|
20
|
+
}
|
|
21
|
+
get browsing() {
|
|
22
|
+
return this.cursor !== null;
|
|
23
|
+
}
|
|
24
|
+
get length() {
|
|
25
|
+
return this.entries.length;
|
|
26
|
+
}
|
|
27
|
+
record(draft) {
|
|
28
|
+
this.abandonNavigation();
|
|
29
|
+
const chars = draftChars(draft);
|
|
30
|
+
// A giant paste is already retained by the conversation history. Do not duplicate an entry larger
|
|
31
|
+
// than the entire composer-history budget just to support Up-arrow recall.
|
|
32
|
+
if (chars > this.maxChars || this.maxEntries <= 0)
|
|
33
|
+
return;
|
|
34
|
+
this.entries.push({ draft: cloneDraft(draft), chars });
|
|
35
|
+
this.totalChars += chars;
|
|
36
|
+
while (this.entries.length > this.maxEntries || this.totalChars > this.maxChars) {
|
|
37
|
+
this.totalChars -= this.entries.shift().chars;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
older(current) {
|
|
41
|
+
if (!this.entries.length)
|
|
42
|
+
return null;
|
|
43
|
+
if (this.cursor === null) {
|
|
44
|
+
this.scratch = cloneDraft(current);
|
|
45
|
+
this.cursor = this.entries.length - 1;
|
|
46
|
+
}
|
|
47
|
+
else if (this.cursor > 0) {
|
|
48
|
+
this.cursor--;
|
|
49
|
+
}
|
|
50
|
+
return cloneDraft(this.entries[this.cursor].draft);
|
|
51
|
+
}
|
|
52
|
+
newer() {
|
|
53
|
+
if (this.cursor === null)
|
|
54
|
+
return null;
|
|
55
|
+
if (this.cursor < this.entries.length - 1) {
|
|
56
|
+
this.cursor++;
|
|
57
|
+
return cloneDraft(this.entries[this.cursor].draft);
|
|
58
|
+
}
|
|
59
|
+
const draft = this.scratch ?? { value: "", attachments: [], pastes: [] };
|
|
60
|
+
this.cursor = null;
|
|
61
|
+
this.scratch = null;
|
|
62
|
+
return cloneDraft(draft);
|
|
63
|
+
}
|
|
64
|
+
abandonNavigation() {
|
|
65
|
+
this.cursor = null;
|
|
66
|
+
this.scratch = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let segmenter;
|
|
70
|
+
function graphemeSegmenter() {
|
|
71
|
+
// Constructing Intl.Segmenter loads ICU data. Defer that cost until the first actual cursor edit so
|
|
72
|
+
// lightweight commands such as `hara --version` do not pay for TUI Unicode support.
|
|
73
|
+
if (segmenter === undefined)
|
|
74
|
+
segmenter = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
|
|
75
|
+
return segmenter;
|
|
76
|
+
}
|
|
77
|
+
/** Previous user-perceived character boundary (keeps emoji ZWJ sequences/combining marks intact). */
|
|
78
|
+
export function previousGraphemeIndex(value, cursor) {
|
|
79
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
80
|
+
if (at === 0)
|
|
81
|
+
return 0;
|
|
82
|
+
const segments = graphemeSegmenter();
|
|
83
|
+
if (!segments) {
|
|
84
|
+
const low = value.charCodeAt(at - 1);
|
|
85
|
+
const paired = low >= 0xdc00 && low <= 0xdfff && at > 1 && value.charCodeAt(at - 2) >= 0xd800 && value.charCodeAt(at - 2) <= 0xdbff;
|
|
86
|
+
return Math.max(0, at - (paired ? 2 : 1));
|
|
87
|
+
}
|
|
88
|
+
let previous = 0;
|
|
89
|
+
for (const part of segments.segment(value)) {
|
|
90
|
+
if (part.index >= at)
|
|
91
|
+
break;
|
|
92
|
+
previous = part.index;
|
|
93
|
+
}
|
|
94
|
+
return previous;
|
|
95
|
+
}
|
|
96
|
+
/** Next user-perceived character boundary. */
|
|
97
|
+
export function nextGraphemeIndex(value, cursor) {
|
|
98
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
99
|
+
if (at >= value.length)
|
|
100
|
+
return value.length;
|
|
101
|
+
const segments = graphemeSegmenter();
|
|
102
|
+
if (!segments) {
|
|
103
|
+
const cp = value.codePointAt(at);
|
|
104
|
+
return Math.min(value.length, at + (cp !== undefined && cp > 0xffff ? 2 : 1));
|
|
105
|
+
}
|
|
106
|
+
for (const part of segments.segment(value)) {
|
|
107
|
+
const end = part.index + part.segment.length;
|
|
108
|
+
if (end > at)
|
|
109
|
+
return end;
|
|
110
|
+
}
|
|
111
|
+
return value.length;
|
|
112
|
+
}
|
|
113
|
+
/** Start of the previous shell-style word, used by Ctrl+W. */
|
|
114
|
+
export function previousWordIndex(value, cursor) {
|
|
115
|
+
let at = Math.max(0, Math.min(value.length, cursor));
|
|
116
|
+
while (at > 0) {
|
|
117
|
+
const previous = previousGraphemeIndex(value, at);
|
|
118
|
+
if (!/\s/u.test(value.slice(previous, at)))
|
|
119
|
+
break;
|
|
120
|
+
at = previous;
|
|
121
|
+
}
|
|
122
|
+
while (at > 0) {
|
|
123
|
+
const previous = previousGraphemeIndex(value, at);
|
|
124
|
+
if (/\s/u.test(value.slice(previous, at)))
|
|
125
|
+
break;
|
|
126
|
+
at = previous;
|
|
127
|
+
}
|
|
128
|
+
return at;
|
|
129
|
+
}
|
|
130
|
+
function graphemeFloor(value, cursor) {
|
|
131
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
132
|
+
if (at === value.length)
|
|
133
|
+
return at;
|
|
134
|
+
const segments = graphemeSegmenter();
|
|
135
|
+
if (!segments)
|
|
136
|
+
return at > 0 && /[\uDC00-\uDFFF]/.test(value[at]) ? at - 1 : at;
|
|
137
|
+
let floor = 0;
|
|
138
|
+
for (const part of segments.segment(value)) {
|
|
139
|
+
if (part.index > at)
|
|
140
|
+
break;
|
|
141
|
+
floor = part.index;
|
|
142
|
+
if (part.index + part.segment.length === at)
|
|
143
|
+
return at;
|
|
144
|
+
}
|
|
145
|
+
return floor;
|
|
146
|
+
}
|
|
147
|
+
/** Move between logical pasted/typed lines while retaining the closest possible column. */
|
|
148
|
+
export function moveCursorLine(value, cursor, direction) {
|
|
149
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
150
|
+
const lineStart = (at === 0 ? -1 : value.lastIndexOf("\n", at - 1)) + 1;
|
|
151
|
+
const lineEndAt = value.indexOf("\n", at);
|
|
152
|
+
const lineEnd = lineEndAt < 0 ? value.length : lineEndAt;
|
|
153
|
+
const column = Math.min(at - lineStart, lineEnd - lineStart);
|
|
154
|
+
if (direction < 0) {
|
|
155
|
+
if (lineStart === 0)
|
|
156
|
+
return at;
|
|
157
|
+
const previousEnd = lineStart - 1;
|
|
158
|
+
const previousStart = value.lastIndexOf("\n", Math.max(0, previousEnd - 1)) + 1;
|
|
159
|
+
return graphemeFloor(value, previousStart + Math.min(column, previousEnd - previousStart));
|
|
160
|
+
}
|
|
161
|
+
if (lineEnd === value.length)
|
|
162
|
+
return at;
|
|
163
|
+
const nextStart = lineEnd + 1;
|
|
164
|
+
const nextEndAt = value.indexOf("\n", nextStart);
|
|
165
|
+
const nextEnd = nextEndAt < 0 ? value.length : nextEndAt;
|
|
166
|
+
return graphemeFloor(value, nextStart + Math.min(column, nextEnd - nextStart));
|
|
167
|
+
}
|
package/dist/undo.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// In-session undo stack for file changes. Each edit tool records the prior state of the files it
|
|
2
2
|
// touched; `/undo` pops the last group and restores it. Process-scoped (one REPL session).
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import { atomicWriteText } from "./fs-write.js";
|
|
5
|
+
import { invalidateFileCandidates } from "./context/mentions.js";
|
|
5
6
|
const stack = [];
|
|
6
7
|
const MAX = 50;
|
|
7
8
|
/** Record a group of file changes (one tool call = one undo step). */
|
|
@@ -27,8 +28,7 @@ export async function undoLast() {
|
|
|
27
28
|
await unlink(s.absPath).catch(() => { }); // was newly created → remove
|
|
28
29
|
}
|
|
29
30
|
else {
|
|
30
|
-
await
|
|
31
|
-
await writeFile(s.absPath, s.before, "utf8");
|
|
31
|
+
await atomicWriteText(s.absPath, s.before);
|
|
32
32
|
}
|
|
33
33
|
files.push(s.path);
|
|
34
34
|
}
|
|
@@ -36,5 +36,7 @@ export async function undoLast() {
|
|
|
36
36
|
/* skip a file we can't restore */
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
if (files.length)
|
|
40
|
+
invalidateFileCandidates();
|
|
39
41
|
return { files };
|
|
40
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanhara/hara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.0",
|
|
4
4
|
"description": "hara — a coding agent CLI that runs like an engineering org.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hara": "dist/index.js"
|
|
@@ -70,5 +70,10 @@
|
|
|
70
70
|
"optionalDependencies": {
|
|
71
71
|
"@zvec/zvec": "^0.5.0",
|
|
72
72
|
"qrcode-terminal": "^0.12.0"
|
|
73
|
+
},
|
|
74
|
+
"overrides": {
|
|
75
|
+
"@larksuiteoapi/node-sdk": {
|
|
76
|
+
"axios": "1.18.1"
|
|
77
|
+
}
|
|
73
78
|
}
|
|
74
79
|
}
|