@nanhara/hara 0.113.0 → 0.115.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 +29 -0
- package/dist/agent/loop.js +13 -3
- package/dist/agent/repeat-guard.js +42 -0
- package/dist/cron/deliver.js +0 -9
- package/dist/gateway/flows.js +89 -0
- package/dist/index.js +40 -0
- package/dist/serve/protocol.js +61 -0
- package/dist/serve/server.js +246 -0
- package/dist/serve/sessions.js +67 -0
- package/dist/tools/all.js +19 -0
- package/dist/tools/builtin.js +30 -2
- package/dist/tools/edit.js +3 -2
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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.115.0 — serve exposes plugins & skills (desktop plugin panel)
|
|
9
|
+
|
|
10
|
+
- **`hara serve` protocol grows a plugin surface**: `plugins.list` (installed plugins with enabled state +
|
|
11
|
+
contribution counts), `plugins.set` (enable/disable by name — applies to future sessions/turns), and
|
|
12
|
+
`skills.list` (the skill index for a cwd). This powers the hara desktop app's plugin manager panel;
|
|
13
|
+
any WS client gets it for free.
|
|
14
|
+
|
|
15
|
+
## 0.114.0 — long files read in slices · repeat-guard anti-spinning · `hara serve` (the desktop/IDE backbone)
|
|
16
|
+
|
|
17
|
+
- **Long files no longer flood the context.** `read_file` now returns cat-n numbered lines with
|
|
18
|
+
`offset`/`limit` slicing (2000-line default window, per-line truncation, a header that says how to
|
|
19
|
+
continue). The old behavior dumped the whole file (100K-char cap) — ~25k tokens per read, tail
|
|
20
|
+
unreachable. Paired prompt rules: grep-then-slice on long files, and **no whole-file re-read after a
|
|
21
|
+
successful edit** (the slowest habit an agent can have). This is the "hara is slow on long files" fix.
|
|
22
|
+
- **Repeat-guard — the anti-spinning tripwire.** When the EXACT same tool call (same tool, same
|
|
23
|
+
arguments) fails twice in a row, the result now carries an explicit "repeating this unchanged will
|
|
24
|
+
fail again — change something or ask the user" note. The guardian breaker covers DENIED actions;
|
|
25
|
+
this covers FAILED ones (observed: 4× the same `git pull` into a dead network). Successes reset the
|
|
26
|
+
streak; `/reset` clears it. Plus prompt rules: diagnose before retrying, two failed variants → step
|
|
27
|
+
back and re-plan.
|
|
28
|
+
- **`hara serve` — a persistent local server (WebSocket JSON-RPC v1)** that desktop shells, ACP and
|
|
29
|
+
IDE clients drive: `initialize` (token auth) · `session.create/resume/list/send/interrupt` ·
|
|
30
|
+
streamed `event.text/reasoning/tool/diff/notice/turn_end` · **approval round-trips**
|
|
31
|
+
(`approval.request` ⇄ `approval.reply`, 5-min deny-timeout, deny-on-disconnect) · sessions are the
|
|
32
|
+
SAME `~/.hara/sessions` store the CLI uses (single-writer lock respected) · `~/.hara/serve.json`
|
|
33
|
+
discovery file. This is the backbone the new hara desktop app (Tauri) speaks to.
|
|
34
|
+
- `tools/all.ts` — library entries (serve, embedders) now register the full built-in toolset; an
|
|
35
|
+
unregistered tool was silently unplannable.
|
|
36
|
+
|
|
8
37
|
## 0.113.0 — DeepSeek reasoning control (thinking + effort, incl. `max`) · host-unreachable memory
|
|
9
38
|
|
|
10
39
|
- **DeepSeek reasoning is now a real dial.** DeepSeek's V4 models (`deepseek-v4-pro` / `deepseek-v4-flash`)
|
package/dist/agent/loop.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runHooks } from "../hooks.js";
|
|
|
8
8
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
9
9
|
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
10
|
import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
|
|
11
|
+
import { recordCall } from "./repeat-guard.js";
|
|
11
12
|
import { subdirHint } from "../context/subdir-hints.js";
|
|
12
13
|
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
13
14
|
import { currentTodos, renderTodos } from "../tools/todo.js";
|
|
@@ -57,7 +58,14 @@ them whole. Batch INDEPENDENT tool calls in a single response — especially rea
|
|
|
57
58
|
glob / ls run in PARALLEL when requested together); one-call-per-turn exploration is the slowest thing
|
|
58
59
|
you can do. When analyzing a project, start wide in ONE batch — manifest (package.json / Cargo.toml /
|
|
59
60
|
pyproject.toml / go.mod), README, build/CI config — then chase only what the task needs with narrow
|
|
60
|
-
grep/glob; don't read whole large files when a targeted search answers the question. For
|
|
61
|
+
grep/glob; don't read whole large files when a targeted search answers the question. For a long file,
|
|
62
|
+
grep to locate then read_file just that region with offset/limit — not the whole file. After a successful
|
|
63
|
+
edit_file/write_file do NOT re-read the file to verify — the tool already applied and diffed the change;
|
|
64
|
+
re-reading a big file after every edit is the slowest habit an agent can have.
|
|
65
|
+
When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
|
|
66
|
+
change something (arguments / approach / tool) before trying again. After two failed variants of the same
|
|
67
|
+
approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
|
|
68
|
+
the errors said. Repeating a failed action hoping for a different result is how sessions die. For broad,
|
|
61
69
|
open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
|
|
62
70
|
independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
|
|
63
71
|
mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
|
|
@@ -437,11 +445,13 @@ export async function runAgent(history, opts) {
|
|
|
437
445
|
}
|
|
438
446
|
const res = await p.tool.run(p.tu.input, ctx);
|
|
439
447
|
// append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
|
|
440
|
-
|
|
448
|
+
// + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
|
|
449
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res) };
|
|
441
450
|
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
442
451
|
}
|
|
443
452
|
catch (e) {
|
|
444
|
-
|
|
453
|
+
const msg = `Error: ${e.message}`;
|
|
454
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
445
455
|
}
|
|
446
456
|
finally {
|
|
447
457
|
activity.dec();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Repeat guard — the anti-spinning tripwire. The classic way an agent wastes a session is repeating the
|
|
2
|
+
// EXACT same failing tool call, unchanged, expecting a different result (observed: 4x `git pull` into the
|
|
3
|
+
// same wall; Nx the same failing build command). The guardian breaker only covers DENIED actions; this
|
|
4
|
+
// covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
|
|
5
|
+
// when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
|
|
6
|
+
// repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
|
|
7
|
+
// or a re-run after a fix is legitimate, and a success resets the failure streak.
|
|
8
|
+
const seen = new Map();
|
|
9
|
+
/** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
|
|
10
|
+
* so a space separator is unambiguous). */
|
|
11
|
+
export function keyOf(name, input) {
|
|
12
|
+
try {
|
|
13
|
+
return name + " " + JSON.stringify(input ?? {});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return name + " <unserializable>";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Does a tool RESULT string look like a failure? hara tools report failures as ordinary strings
|
|
20
|
+
* (bash -> "Command failed: ...", file tools -> "Error: ...", net guard -> "Skipped without running: ..."),
|
|
21
|
+
* so the loop's isError flag alone misses them. Pure — exported for tests. */
|
|
22
|
+
export function looksFailed(content) {
|
|
23
|
+
return /^\s*(Command failed|Error\b|Skipped without running)/.test(content);
|
|
24
|
+
}
|
|
25
|
+
/** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
|
|
26
|
+
* failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
|
|
27
|
+
export function recordCall(name, input, content, isError = false) {
|
|
28
|
+
const k = keyOf(name, input);
|
|
29
|
+
const failed = isError || looksFailed(content);
|
|
30
|
+
const s = seen.get(k) ?? { fails: 0 };
|
|
31
|
+
s.fails = failed ? s.fails + 1 : 0; // success resets the streak
|
|
32
|
+
seen.set(k, s);
|
|
33
|
+
if (s.fails < 2)
|
|
34
|
+
return "";
|
|
35
|
+
return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
|
|
36
|
+
`repeating it unchanged will fail again. Read the error above, change something (arguments / approach / tool), ` +
|
|
37
|
+
`or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
|
|
38
|
+
}
|
|
39
|
+
/** Clear the streaks — /reset (fresh start) and tests. */
|
|
40
|
+
export function resetRepeatGuard() {
|
|
41
|
+
seen.clear();
|
|
42
|
+
}
|
package/dist/cron/deliver.js
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
// Cron result delivery — push a finished job's output to a chat channel (openclaw/hermes parity),
|
|
2
|
-
// WITHOUT needing the gateway process: adapters are constructed one-shot from the same env vars the
|
|
3
|
-
// gateway uses, send once, and are dropped. Spec format: "<target>:<id>" —
|
|
4
|
-
// telegram:<chatId> (HARA_TELEGRAM_TOKEN)
|
|
5
|
-
// feishu:<chatId> (HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET)
|
|
6
|
-
// webhook:<url> (plain POST {name,status,text} JSON — for anything else)
|
|
7
|
-
// WeChat is intentionally absent: its transport needs the long-lived gateway session, so a one-shot
|
|
8
|
-
// cron process can't speak it — use feishu/telegram/webhook for cron delivery.
|
|
9
|
-
// Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
|
|
10
1
|
/** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
|
|
11
2
|
export function parseDeliver(spec) {
|
|
12
3
|
const i = spec.indexOf(":");
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// hara gateway flows — user-configured rules that intercept inbound gateway messages and route matching
|
|
2
|
+
// ones to an agent task + a delivery target, instead of the gateway's default DM-driver reply. This turns
|
|
3
|
+
// any chat gateway (Telegram / WeChat / Feishu / Slack / …) into an automation trigger: "when a message
|
|
4
|
+
// matching <trigger> arrives, run <agent task> and deliver the result to <target>".
|
|
5
|
+
//
|
|
6
|
+
// Opt-in: config lives in the user's ~/.hara/flows.json — no file, no flows (zero behaviour change).
|
|
7
|
+
// Platform-agnostic: matching only reads the generic InboundMsg fields each adapter populates (chatType,
|
|
8
|
+
// mentions), so a flow works on whatever platform surfaces the data it asks for.
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { deliverResult } from "../cron/deliver.js";
|
|
13
|
+
const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
14
|
+
/** Load ~/.hara/flows.json — accepts a bare array or `{ "flows": [...] }`. Missing/malformed → [] (never throws). */
|
|
15
|
+
export function loadFlows() {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(readFileSync(join(homedir(), ".hara", "flows.json"), "utf8"));
|
|
18
|
+
const flows = Array.isArray(parsed) ? parsed : parsed?.flows;
|
|
19
|
+
return Array.isArray(flows) ? flows.filter((f) => f && f.enabled !== false && f.name && f.do) : [];
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Pure predicate: does message `m` on `platform` satisfy rule `r`'s trigger? */
|
|
26
|
+
export function matchFlow(r, m, platform) {
|
|
27
|
+
const on = r.on ?? {};
|
|
28
|
+
if (on.platform && on.platform.toLowerCase() !== platform.toLowerCase())
|
|
29
|
+
return false;
|
|
30
|
+
const chats = asArray(on.chat);
|
|
31
|
+
if (chats.length && !chats.includes(String(m.chatId)))
|
|
32
|
+
return false;
|
|
33
|
+
if (on.chatType && on.chatType !== "any") {
|
|
34
|
+
if (!m.chatType || m.chatType !== on.chatType)
|
|
35
|
+
return false; // rule wants a specific kind the adapter didn't confirm
|
|
36
|
+
}
|
|
37
|
+
if (on.mention && on.mention !== "any") {
|
|
38
|
+
const ms = m.mentions ?? [];
|
|
39
|
+
if (on.mention === "self") {
|
|
40
|
+
if (!ms.some((x) => x.isSelf))
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const want = asArray(on.mention);
|
|
45
|
+
if (!ms.some((x) => x.id && want.includes(x.id)))
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const kws = asArray(on.keyword);
|
|
50
|
+
if (kws.length && !kws.some((k) => (m.text ?? "").includes(k)))
|
|
51
|
+
return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/** Compose the agent prompt for a matched flow (English scaffolding; the user's do/guard carry the intent). */
|
|
55
|
+
export function buildFlowPrompt(r, m) {
|
|
56
|
+
return (r.do +
|
|
57
|
+
(r.guard ? `\n\nConstraint: ${r.guard}` : "") +
|
|
58
|
+
`\n\n--- Triggering message ---\nchat ${m.chatId}${m.chatType ? ` (${m.chatType})` : ""} · from ${m.userName || m.userId}\n${m.text}`);
|
|
59
|
+
}
|
|
60
|
+
/** Try to handle `m` via configured flows. Returns true if ≥1 rule matched (caller should STOP default routing).
|
|
61
|
+
* The agent run + delivery are fire-and-forget so a slow LLM call never blocks the gateway's event loop.
|
|
62
|
+
* `runAgent` runs the prompt (injected by the gateway so we reuse its session/env plumbing); `reply` (optional)
|
|
63
|
+
* sends text back to the originating chat. */
|
|
64
|
+
export async function dispatchFlows(m, platform, runAgent, reply) {
|
|
65
|
+
const matched = loadFlows().filter((r) => matchFlow(r, m, platform));
|
|
66
|
+
if (!matched.length)
|
|
67
|
+
return false;
|
|
68
|
+
for (const r of matched) {
|
|
69
|
+
console.error(`hara flow: "${r.name}" matched · ${platform} ${m.chatType ?? "?"} ${m.chatId}`);
|
|
70
|
+
void (async () => {
|
|
71
|
+
try {
|
|
72
|
+
const output = (await runAgent(buildFlowPrompt(r, m))).trim();
|
|
73
|
+
if (!output)
|
|
74
|
+
return;
|
|
75
|
+
if (r.deliver) {
|
|
76
|
+
const err = await deliverResult(r.deliver, output);
|
|
77
|
+
if (err)
|
|
78
|
+
console.error(`hara flow "${r.name}": deliver failed — ${err}`);
|
|
79
|
+
}
|
|
80
|
+
if (r.reply && reply)
|
|
81
|
+
await reply(output).catch(() => { });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
console.error(`hara flow "${r.name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,7 @@ 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
42
|
import { resetReachability } from "./tools/net-reachability.js";
|
|
43
|
+
import { resetRepeatGuard } from "./agent/repeat-guard.js";
|
|
43
44
|
import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
44
45
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
45
46
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -1553,6 +1554,44 @@ program
|
|
|
1553
1554
|
const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
|
|
1554
1555
|
await mod.runGateway({ cwd, platform: opts.platform });
|
|
1555
1556
|
});
|
|
1557
|
+
program
|
|
1558
|
+
.command("serve")
|
|
1559
|
+
.description("run the local hara server (WebSocket JSON-RPC) that desktop shells / IDE clients drive — persistent sessions, streaming events, approval round-trips")
|
|
1560
|
+
.option("--host <host>", "bind address — keep it loopback unless you know what you're doing", "127.0.0.1")
|
|
1561
|
+
.option("--port <n>", "port to listen on", "8790")
|
|
1562
|
+
.option("--token <token>", "auth token (default: generated; written to ~/.hara/serve.json for clients to discover)")
|
|
1563
|
+
.option("--cwd <dir>", "default working directory for new sessions (default: current directory)")
|
|
1564
|
+
.option("--approval <mode>", "default approval mode for sessions: suggest | auto-edit | full-auto", "auto-edit")
|
|
1565
|
+
.action(async (o) => {
|
|
1566
|
+
const cfg = loadConfig();
|
|
1567
|
+
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
1568
|
+
if (!provider0) {
|
|
1569
|
+
out(c.red(`Not authenticated for '${cfg.provider}' — run \`hara setup\` first.\n`));
|
|
1570
|
+
process.exit(1);
|
|
1571
|
+
}
|
|
1572
|
+
const guardianOpt = await buildGuardian(cfg, provider0);
|
|
1573
|
+
const cwd = o.cwd ? (await import("node:path")).resolve(o.cwd) : process.cwd();
|
|
1574
|
+
const sandbox = (process.env.HARA_SANDBOX ?? cfg.sandbox ?? "off");
|
|
1575
|
+
const approval = APPROVAL_MODES.includes(o.approval) ? o.approval : "auto-edit";
|
|
1576
|
+
const { startServe } = await import("./serve/server.js");
|
|
1577
|
+
const handle = await startServe({ host: o.host, port: Number(o.port) || 8790, token: o.token, cwd }, {
|
|
1578
|
+
version: pkg.version,
|
|
1579
|
+
providerId: cfg.provider,
|
|
1580
|
+
model: cfg.model,
|
|
1581
|
+
buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
|
|
1582
|
+
spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
|
|
1583
|
+
guardian: guardianOpt,
|
|
1584
|
+
sandbox,
|
|
1585
|
+
approval,
|
|
1586
|
+
});
|
|
1587
|
+
out(c.bold("hara serve") + c.dim(` · ws://${o.host}:${handle.port} · ${cfg.provider}:${cfg.model} · approval ${approval} · token → ~/.hara/serve.json\n`));
|
|
1588
|
+
const bye = async () => {
|
|
1589
|
+
await handle.close();
|
|
1590
|
+
process.exit(0);
|
|
1591
|
+
};
|
|
1592
|
+
process.on("SIGINT", bye);
|
|
1593
|
+
process.on("SIGTERM", bye);
|
|
1594
|
+
});
|
|
1556
1595
|
program
|
|
1557
1596
|
.command("remote [action] [text]")
|
|
1558
1597
|
.description("drive THIS tmux session from chat: register the pane so WeChat replies inject back into it. actions: ask \"<q>\" | bind | back | status")
|
|
@@ -2928,6 +2967,7 @@ program.action(async (opts) => {
|
|
|
2928
2967
|
history.length = 0;
|
|
2929
2968
|
recalledContext = "";
|
|
2930
2969
|
resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
|
|
2970
|
+
resetRepeatGuard(); // …and the repeated-failure streaks (the user may have fixed the cause)
|
|
2931
2971
|
return void h.sink.notice("(context cleared)");
|
|
2932
2972
|
}
|
|
2933
2973
|
if (nm === "undo") {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// hara serve protocol v1 — JSON-RPC 2.0 over WebSocket text frames. This is the contract the desktop
|
|
2
|
+
// shell (and any ACP/IDE client) speaks; the transport lives in server.ts, sessions in sessions.ts.
|
|
3
|
+
// Everything here is PURE (parse + frame builders + error codes) and unit-tested.
|
|
4
|
+
//
|
|
5
|
+
// Client → server requests:
|
|
6
|
+
// initialize {token} → {name,version,protocol,cwd,provider,model}
|
|
7
|
+
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
8
|
+
// session.create {cwd?,approval?} → {sessionId,model}
|
|
9
|
+
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
10
|
+
// session.send {sessionId,text} → (streams events, then) {reply,usage}
|
|
11
|
+
// session.interrupt {sessionId} → {}
|
|
12
|
+
// approval.reply {approvalId,allow,always?} → {}
|
|
13
|
+
// plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
|
|
14
|
+
// plugins.set {name,enabled} → {name,enabled} (applies to future sessions/turns)
|
|
15
|
+
// skills.list {cwd?} → {skills:[{id,description,source}]}
|
|
16
|
+
// Server → client notifications (all carry sessionId):
|
|
17
|
+
// event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
|
|
18
|
+
// event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
|
|
19
|
+
export const PROTOCOL_VERSION = 1;
|
|
20
|
+
/** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
|
|
21
|
+
export const ERR = {
|
|
22
|
+
PARSE: -32700,
|
|
23
|
+
INVALID: -32600,
|
|
24
|
+
METHOD: -32601,
|
|
25
|
+
PARAMS: -32602,
|
|
26
|
+
INTERNAL: -32603,
|
|
27
|
+
UNAUTHORIZED: -32001, // initialize first (or bad token)
|
|
28
|
+
BUSY: -32002, // session already has a turn in flight
|
|
29
|
+
NO_SESSION: -32003, // unknown/expired sessionId
|
|
30
|
+
LOCKED: -32004, // session held by another live hara process (single-writer lock)
|
|
31
|
+
};
|
|
32
|
+
/** Parse one inbound text frame into a request. Returns {error} (never throws) on malformed input —
|
|
33
|
+
* the transport turns that into a PARSE/INVALID error response. */
|
|
34
|
+
export function parseFrame(raw) {
|
|
35
|
+
let v;
|
|
36
|
+
try {
|
|
37
|
+
v = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { error: "not JSON" };
|
|
41
|
+
}
|
|
42
|
+
const o = v;
|
|
43
|
+
if (!o || typeof o !== "object" || o.jsonrpc !== "2.0" || typeof o.method !== "string" || !o.method) {
|
|
44
|
+
return { error: "not a JSON-RPC 2.0 request" };
|
|
45
|
+
}
|
|
46
|
+
if (o.id !== undefined && typeof o.id !== "number" && typeof o.id !== "string")
|
|
47
|
+
return { error: "bad id" };
|
|
48
|
+
if (o.params !== undefined && (typeof o.params !== "object" || o.params === null || Array.isArray(o.params))) {
|
|
49
|
+
return { error: "params must be an object" };
|
|
50
|
+
}
|
|
51
|
+
return { req: o };
|
|
52
|
+
}
|
|
53
|
+
export function rpcResult(id, result) {
|
|
54
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
55
|
+
}
|
|
56
|
+
export function rpcError(id, code, message) {
|
|
57
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
58
|
+
}
|
|
59
|
+
export function rpcNotify(method, params) {
|
|
60
|
+
return JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
61
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// hara serve — the persistent local server (WebSocket JSON-RPC, protocol.ts) that desktop shells, ACP
|
|
2
|
+
// clients, and IDE plugins drive. codex's app-server layering in TypeScript: shell ↔ protocol ↔ agent
|
|
3
|
+
// core, with the agent core (runAgent + plugins + skills + memory) running IN-PROCESS — plugins need no
|
|
4
|
+
// bridging. Provider building / subagent spawn / guardian stay in index.ts and are injected as ServeDeps
|
|
5
|
+
// (no import cycle back into the CLI entry).
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
|
|
8
|
+
import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
|
|
12
|
+
import { runAgent } from "../agent/loop.js";
|
|
13
|
+
import { loadAgentsMd } from "../context/agents-md.js";
|
|
14
|
+
import { memoryDigest } from "../memory/store.js";
|
|
15
|
+
import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
|
|
16
|
+
import { loadSkillIndex } from "../skills/skills.js";
|
|
17
|
+
import { SessionHub, realStore } from "./sessions.js";
|
|
18
|
+
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
19
|
+
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
20
|
+
const sameToken = (a, b) => {
|
|
21
|
+
// constant-time compare over digests (inputs differ in length)
|
|
22
|
+
const ha = createHash("sha256").update(a).digest();
|
|
23
|
+
const hb = createHash("sha256").update(b).digest();
|
|
24
|
+
return timingSafeEqual(ha, hb);
|
|
25
|
+
};
|
|
26
|
+
/** Last assistant text in a history — the turn's "reply" for request/response clients. */
|
|
27
|
+
export function lastAssistantText(history) {
|
|
28
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
29
|
+
const m = history[i];
|
|
30
|
+
if (m.role === "assistant")
|
|
31
|
+
return m.text ?? "";
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
/** Compact history for session.resume — enough for a client to render the transcript. */
|
|
36
|
+
export function historyForClient(history) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const m of history) {
|
|
39
|
+
if (m.role === "user")
|
|
40
|
+
out.push({ role: "user", text: m.content });
|
|
41
|
+
else if (m.role === "assistant" && m.text)
|
|
42
|
+
out.push({ role: "assistant", text: m.text });
|
|
43
|
+
// tool results are omitted — clients see live tool events; persisted detail stays in the store
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export async function startServe(opts, deps) {
|
|
48
|
+
const token = opts.token ?? randomBytes(16).toString("hex");
|
|
49
|
+
const hub = new SessionHub(deps.store ?? realStore);
|
|
50
|
+
const wss = new WebSocketServer({ host: opts.host, port: opts.port, maxPayload: 10 * 1024 * 1024 });
|
|
51
|
+
await new Promise((res, rej) => {
|
|
52
|
+
wss.once("listening", res);
|
|
53
|
+
wss.once("error", rej);
|
|
54
|
+
});
|
|
55
|
+
const port = wss.address().port;
|
|
56
|
+
const authed = new Set();
|
|
57
|
+
const pendingApprovals = new Map();
|
|
58
|
+
const broadcast = (method, params) => {
|
|
59
|
+
const frame = rpcNotify(method, params);
|
|
60
|
+
for (const ws of authed)
|
|
61
|
+
if (ws.readyState === ws.OPEN)
|
|
62
|
+
ws.send(frame);
|
|
63
|
+
};
|
|
64
|
+
// Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
|
|
65
|
+
const discoveryPath = join(homedir(), ".hara", "serve.json");
|
|
66
|
+
if (!deps.quietDiscovery) {
|
|
67
|
+
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
68
|
+
writeFileSync(discoveryPath, JSON.stringify({ host: opts.host, port, token, pid: process.pid, version: deps.version }, null, 2), { mode: 0o600 });
|
|
69
|
+
}
|
|
70
|
+
/** Run one turn on a session, streaming events to all authed clients. */
|
|
71
|
+
const runTurn = async (s, text) => {
|
|
72
|
+
const sessionId = s.meta.id;
|
|
73
|
+
s.busy = true;
|
|
74
|
+
s.abort = new AbortController();
|
|
75
|
+
const before = { input: s.stats.input, output: s.stats.output };
|
|
76
|
+
const sink = {
|
|
77
|
+
text: (d) => broadcast("event.text", { sessionId, delta: d }),
|
|
78
|
+
reasoning: (d) => broadcast("event.reasoning", { sessionId, delta: d }),
|
|
79
|
+
tool: (name, preview) => broadcast("event.tool", { sessionId, name, preview }),
|
|
80
|
+
diff: (t) => broadcast("event.diff", { sessionId, text: t }),
|
|
81
|
+
notice: (t) => broadcast("event.notice", { sessionId, text: t }),
|
|
82
|
+
};
|
|
83
|
+
const confirm = (q) => new Promise((resolve) => {
|
|
84
|
+
const approvalId = randomUUID();
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
if (pendingApprovals.delete(approvalId))
|
|
87
|
+
resolve(false); // unanswered → deny, turn continues
|
|
88
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
89
|
+
pendingApprovals.set(approvalId, (v) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
pendingApprovals.delete(approvalId);
|
|
92
|
+
resolve(v);
|
|
93
|
+
});
|
|
94
|
+
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
s.history.push({ role: "user", content: text });
|
|
98
|
+
await runAgent(s.history, {
|
|
99
|
+
provider: s.provider,
|
|
100
|
+
ctx: {
|
|
101
|
+
cwd: s.meta.cwd,
|
|
102
|
+
sandbox: deps.sandbox,
|
|
103
|
+
spawn: (t, role) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role),
|
|
104
|
+
ui: sink,
|
|
105
|
+
},
|
|
106
|
+
approval: s.approval,
|
|
107
|
+
confirm,
|
|
108
|
+
autoApprove: s.autoApprove,
|
|
109
|
+
projectContext: s.projectContext,
|
|
110
|
+
memory: memoryDigest(s.meta.cwd),
|
|
111
|
+
stats: s.stats,
|
|
112
|
+
signal: s.abort.signal,
|
|
113
|
+
guardian: deps.guardian,
|
|
114
|
+
});
|
|
115
|
+
hub.save(s);
|
|
116
|
+
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
117
|
+
const reply = lastAssistantText(s.history);
|
|
118
|
+
broadcast("event.turn_end", { sessionId, reply, usage });
|
|
119
|
+
return { reply, usage };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
s.busy = false;
|
|
123
|
+
s.abort = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
wss.on("connection", (ws) => {
|
|
127
|
+
ws.on("message", async (raw) => {
|
|
128
|
+
const parsed = parseFrame(String(raw));
|
|
129
|
+
if ("error" in parsed)
|
|
130
|
+
return void ws.send(rpcError(null, ERR.PARSE, parsed.error));
|
|
131
|
+
const { req } = parsed;
|
|
132
|
+
const id = req.id ?? null;
|
|
133
|
+
const reply = (frame) => void (id !== null && ws.send(frame));
|
|
134
|
+
const p = (req.params ?? {});
|
|
135
|
+
try {
|
|
136
|
+
if (req.method === "initialize") {
|
|
137
|
+
if (typeof p.token !== "string" || !sameToken(p.token, token))
|
|
138
|
+
return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
|
|
139
|
+
authed.add(ws);
|
|
140
|
+
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model }));
|
|
141
|
+
}
|
|
142
|
+
if (!authed.has(ws))
|
|
143
|
+
return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
|
|
144
|
+
switch (req.method) {
|
|
145
|
+
case "session.list":
|
|
146
|
+
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt })) }));
|
|
147
|
+
case "session.create": {
|
|
148
|
+
const provider = await deps.buildSessionProvider();
|
|
149
|
+
if (!provider)
|
|
150
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
|
|
151
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
152
|
+
const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
|
|
153
|
+
const s = hub.create({ cwd, provider, providerId: deps.providerId, model: deps.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
|
|
154
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
|
|
155
|
+
}
|
|
156
|
+
case "session.resume": {
|
|
157
|
+
if (typeof p.sessionId !== "string")
|
|
158
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
159
|
+
const provider = await deps.buildSessionProvider();
|
|
160
|
+
if (!provider)
|
|
161
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
|
|
162
|
+
const r = hub.resume(p.sessionId, { provider, approval: deps.approval, projectContext: undefined });
|
|
163
|
+
if ("missing" in r)
|
|
164
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
165
|
+
if ("lockedBy" in r)
|
|
166
|
+
return reply(rpcError(id, ERR.LOCKED, `session held by live pid ${r.lockedBy}`));
|
|
167
|
+
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
|
168
|
+
return reply(rpcResult(id, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
169
|
+
}
|
|
170
|
+
case "session.send": {
|
|
171
|
+
if (typeof p.sessionId !== "string" || typeof p.text !== "string" || !p.text)
|
|
172
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + text required"));
|
|
173
|
+
const s = hub.get(p.sessionId);
|
|
174
|
+
if (!s)
|
|
175
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
|
|
176
|
+
if (s.busy)
|
|
177
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
|
|
178
|
+
const r = await runTurn(s, p.text);
|
|
179
|
+
return reply(rpcResult(id, r));
|
|
180
|
+
}
|
|
181
|
+
case "session.interrupt": {
|
|
182
|
+
const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
183
|
+
if (!s)
|
|
184
|
+
return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
|
|
185
|
+
s.abort?.abort();
|
|
186
|
+
return reply(rpcResult(id, {}));
|
|
187
|
+
}
|
|
188
|
+
case "approval.reply": {
|
|
189
|
+
if (typeof p.approvalId !== "string")
|
|
190
|
+
return reply(rpcError(id, ERR.PARAMS, "approvalId required"));
|
|
191
|
+
const resolve = pendingApprovals.get(p.approvalId);
|
|
192
|
+
if (resolve)
|
|
193
|
+
resolve(p.always === true ? "always" : p.allow === true);
|
|
194
|
+
return reply(rpcResult(id, {})); // idempotent — a late/duplicate reply is a no-op
|
|
195
|
+
}
|
|
196
|
+
case "plugins.list": {
|
|
197
|
+
const on = new Set(enabledPlugins().map((pl) => pl.name));
|
|
198
|
+
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length })) }));
|
|
199
|
+
}
|
|
200
|
+
case "plugins.set": {
|
|
201
|
+
if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
|
|
202
|
+
return reply(rpcError(id, ERR.PARAMS, "name + enabled required"));
|
|
203
|
+
if (!listInstalled().some((pl) => pl.name === p.name))
|
|
204
|
+
return reply(rpcError(id, ERR.PARAMS, `no installed plugin "${p.name}"`));
|
|
205
|
+
setPluginEnabled(p.name, p.enabled);
|
|
206
|
+
return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
|
|
207
|
+
}
|
|
208
|
+
case "skills.list": {
|
|
209
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
210
|
+
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
|
211
|
+
}
|
|
212
|
+
default:
|
|
213
|
+
return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
ws.on("close", () => {
|
|
221
|
+
authed.delete(ws);
|
|
222
|
+
if (authed.size === 0) {
|
|
223
|
+
// nobody left to answer — deny pending approvals now instead of stalling turns for the timeout
|
|
224
|
+
for (const resolve of pendingApprovals.values())
|
|
225
|
+
resolve(false);
|
|
226
|
+
pendingApprovals.clear();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
const close = async () => {
|
|
231
|
+
for (const resolve of pendingApprovals.values())
|
|
232
|
+
resolve(false);
|
|
233
|
+
pendingApprovals.clear();
|
|
234
|
+
hub.releaseAll();
|
|
235
|
+
if (!deps.quietDiscovery) {
|
|
236
|
+
try {
|
|
237
|
+
unlinkSync(discoveryPath);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
/* already gone */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await new Promise((res) => wss.close(() => res()));
|
|
244
|
+
};
|
|
245
|
+
return { port, token, close };
|
|
246
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deriveTitle, } from "../session/store.js";
|
|
2
|
+
/** The real ~/.hara/sessions store (default). */
|
|
3
|
+
export const realStore = {
|
|
4
|
+
load: loadSession,
|
|
5
|
+
save: saveSession,
|
|
6
|
+
list: listSessions,
|
|
7
|
+
acquire: acquireSessionLock,
|
|
8
|
+
release: releaseSessionLock,
|
|
9
|
+
};
|
|
10
|
+
export class SessionHub {
|
|
11
|
+
store;
|
|
12
|
+
sessions = new Map();
|
|
13
|
+
constructor(store = realStore) {
|
|
14
|
+
this.store = store;
|
|
15
|
+
}
|
|
16
|
+
create(o) {
|
|
17
|
+
const meta = {
|
|
18
|
+
id: newSessionId(),
|
|
19
|
+
cwd: o.cwd,
|
|
20
|
+
provider: o.providerId,
|
|
21
|
+
model: o.model,
|
|
22
|
+
title: "",
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
updatedAt: "",
|
|
25
|
+
};
|
|
26
|
+
this.store.acquire(meta.id); // fresh id — always ours; registers the single-writer claim
|
|
27
|
+
const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
|
|
28
|
+
this.sessions.set(meta.id, s);
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
/** Resume a persisted session. Returns the live session, or a lock/missing failure. */
|
|
32
|
+
resume(id, o) {
|
|
33
|
+
const live = this.sessions.get(id);
|
|
34
|
+
if (live)
|
|
35
|
+
return { session: live }; // already attached to this server
|
|
36
|
+
const prior = this.store.load(id);
|
|
37
|
+
if (!prior)
|
|
38
|
+
return { missing: true };
|
|
39
|
+
const lock = this.store.acquire(id);
|
|
40
|
+
if (!lock.ok)
|
|
41
|
+
return { lockedBy: lock.pid ?? 0 };
|
|
42
|
+
const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
|
|
43
|
+
this.sessions.set(id, s);
|
|
44
|
+
return { session: s };
|
|
45
|
+
}
|
|
46
|
+
get(id) {
|
|
47
|
+
return this.sessions.get(id);
|
|
48
|
+
}
|
|
49
|
+
list(cwd) {
|
|
50
|
+
return this.store.list(cwd);
|
|
51
|
+
}
|
|
52
|
+
/** Persist a session after a turn (sets a title from the first user message once). */
|
|
53
|
+
save(s) {
|
|
54
|
+
if (!s.meta.title) {
|
|
55
|
+
const first = s.history.find((m) => m.role === "user");
|
|
56
|
+
if (first && "content" in first && typeof first.content === "string")
|
|
57
|
+
s.meta.title = deriveTitle(first.content);
|
|
58
|
+
}
|
|
59
|
+
this.store.save(s.meta, s.history);
|
|
60
|
+
}
|
|
61
|
+
/** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
|
|
62
|
+
releaseAll() {
|
|
63
|
+
for (const id of this.sessions.keys())
|
|
64
|
+
this.store.release(id);
|
|
65
|
+
this.sessions.clear();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Side-effect aggregate: register EVERY built-in tool. The CLI entry (index.ts) has always done these
|
|
2
|
+
// imports itself; library entries (serve/server.ts, future embedders) import THIS so the registry is
|
|
3
|
+
// never empty when runAgent plans a turn — an unregistered tool is silently unplannable, which shows up
|
|
4
|
+
// as "the model called write_file and nothing happened".
|
|
5
|
+
import "./builtin.js"; // read_file / write_file / bash / job
|
|
6
|
+
import "./edit.js"; // edit_file
|
|
7
|
+
import "./search.js"; // grep / glob / ls
|
|
8
|
+
import "./patch.js"; // apply_patch
|
|
9
|
+
import "./web.js"; // web_search / web_fetch
|
|
10
|
+
import "./agent.js"; // agent (subagent spawn)
|
|
11
|
+
import "./memory.js"; // memory_search/get/write/forget + skill_create
|
|
12
|
+
import "./skill.js"; // skill loader
|
|
13
|
+
import "./codebase.js"; // codebase_search
|
|
14
|
+
import "./todo.js"; // todo_write
|
|
15
|
+
import "./send.js"; // send_file (self-gates on HARA_GATEWAY)
|
|
16
|
+
import "./external_agent.js"; // external_agent (claude-code / codex delegation)
|
|
17
|
+
import "./ask_user.js"; // ask_user
|
|
18
|
+
import "./cron.js"; // cronjob
|
|
19
|
+
import "./computer.js"; // computer (desktop control; self-gates on config)
|
package/dist/tools/builtin.js
CHANGED
|
@@ -43,20 +43,48 @@ export function capHeadTail(s, max = MAX) {
|
|
|
43
43
|
const head = Math.floor(max * 0.6);
|
|
44
44
|
return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
|
|
45
45
|
}
|
|
46
|
+
const READ_LINES = 2000; // default lines per read_file call (a long file is read in slices, not dumped whole)
|
|
47
|
+
const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
|
|
48
|
+
/** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
|
|
49
|
+
* tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
|
|
50
|
+
* re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
|
|
51
|
+
* "read around line N"), a default window of READ_LINES, and a header that says how to continue. Pure —
|
|
52
|
+
* exported for tests. */
|
|
53
|
+
export function renderFileSlice(text, offset, limit) {
|
|
54
|
+
const lines = text.split("\n");
|
|
55
|
+
// A trailing newline yields one phantom "" line at the end — don't count or show it.
|
|
56
|
+
if (lines.length > 1 && lines[lines.length - 1] === "")
|
|
57
|
+
lines.pop();
|
|
58
|
+
const total = lines.length;
|
|
59
|
+
const start = Math.max(1, Math.floor(offset ?? 1));
|
|
60
|
+
const want = Math.max(1, Math.floor(limit ?? READ_LINES));
|
|
61
|
+
if (start > total)
|
|
62
|
+
return `(file has ${total} lines — offset ${start} is past the end)`;
|
|
63
|
+
const end = Math.min(total, start + want - 1);
|
|
64
|
+
const body = lines
|
|
65
|
+
.slice(start - 1, end)
|
|
66
|
+
.map((l, i) => `${String(start + i).padStart(6)}\t${l.length > LINE_CAP ? l.slice(0, LINE_CAP) + `…[+${l.length - LINE_CAP} chars]` : l}`)
|
|
67
|
+
.join("\n");
|
|
68
|
+
const sliced = start > 1 || end < total;
|
|
69
|
+
const head = sliced ? `(lines ${start}–${end} of ${total}${end < total ? ` — continue with offset:${end + 1}` : ""})\n` : "";
|
|
70
|
+
return head + body;
|
|
71
|
+
}
|
|
46
72
|
registerTool({
|
|
47
73
|
name: "read_file",
|
|
48
|
-
description: "Read a UTF-8 text file and
|
|
74
|
+
description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 2000 lines by default — for a longer file pass offset/limit to read the next slice (the output header tells you the total and where to continue). Prefer grep to locate, then read just that region.",
|
|
49
75
|
input_schema: {
|
|
50
76
|
type: "object",
|
|
51
77
|
properties: {
|
|
52
78
|
path: { type: "string", description: "File path, relative to cwd or absolute" },
|
|
79
|
+
offset: { type: "number", description: "1-based line number to start from (for long files)" },
|
|
80
|
+
limit: { type: "number", description: "max lines to return (default 2000)" },
|
|
53
81
|
},
|
|
54
82
|
required: ["path"],
|
|
55
83
|
},
|
|
56
84
|
kind: "read",
|
|
57
85
|
async run(input, ctx) {
|
|
58
86
|
try {
|
|
59
|
-
return cap(await readFile(abs(input.path, ctx.cwd), "utf8"));
|
|
87
|
+
return cap(renderFileSlice(await readFile(abs(input.path, ctx.cwd), "utf8"), input.offset, input.limit));
|
|
60
88
|
}
|
|
61
89
|
catch (e) {
|
|
62
90
|
const near = nearestPaths(ctx.cwd, input.path);
|
package/dist/tools/edit.js
CHANGED
|
@@ -10,8 +10,9 @@ registerTool({
|
|
|
10
10
|
description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
|
|
11
11
|
"or `edits` (an array of {old_string,new_string,replace_all?}) applied in order. Each `old_string` " +
|
|
12
12
|
"must match exactly and appear once (include surrounding context) unless `replace_all` is true. " +
|
|
13
|
-
"
|
|
14
|
-
"
|
|
13
|
+
"`old_string` is matched against the RAW file text — strip read_file's line-number prefix " +
|
|
14
|
+
"(the leading ` 123\\t`) before matching. Quote variants (straight/curly) are matched leniently. " +
|
|
15
|
+
"Use write_file to create a new file, or apply_patch to change several files at once.",
|
|
15
16
|
input_schema: {
|
|
16
17
|
type: "object",
|
|
17
18
|
properties: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanhara/hara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.0",
|
|
4
4
|
"description": "hara — a coding agent CLI that runs like an engineering org.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hara": "dist/index.js"
|
|
@@ -56,11 +56,13 @@
|
|
|
56
56
|
"commander": "^15.0.0",
|
|
57
57
|
"ink": "^6.8.0",
|
|
58
58
|
"openai": "^6.44.0",
|
|
59
|
-
"react": "^19.2.7"
|
|
59
|
+
"react": "^19.2.7",
|
|
60
|
+
"ws": "^8.19.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/node": "^25.9.3",
|
|
63
64
|
"@types/react": "^19.2.17",
|
|
65
|
+
"@types/ws": "^8.18.1",
|
|
64
66
|
"ink-testing-library": "^4.0.0",
|
|
65
67
|
"tsx": "^4.22.4",
|
|
66
68
|
"typescript": "^6.0.3"
|