@nanhara/hara 0.114.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 CHANGED
@@ -5,6 +5,13 @@ 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
+
8
15
  ## 0.114.0 — long files read in slices · repeat-guard anti-spinning · `hara serve` (the desktop/IDE backbone)
9
16
 
10
17
  - **Long files no longer flood the context.** `read_file` now returns cat-n numbered lines with
@@ -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
+ }
@@ -10,6 +10,9 @@
10
10
  // session.send {sessionId,text} → (streams events, then) {reply,usage}
11
11
  // session.interrupt {sessionId} → {}
12
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}]}
13
16
  // Server → client notifications (all carry sessionId):
14
17
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
15
18
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
@@ -12,6 +12,8 @@ import "../tools/all.js"; // register the full built-in toolset — serve must w
12
12
  import { runAgent } from "../agent/loop.js";
13
13
  import { loadAgentsMd } from "../context/agents-md.js";
14
14
  import { memoryDigest } from "../memory/store.js";
15
+ import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
16
+ import { loadSkillIndex } from "../skills/skills.js";
15
17
  import { SessionHub, realStore } from "./sessions.js";
16
18
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
17
19
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
@@ -191,6 +193,22 @@ export async function startServe(opts, deps) {
191
193
  resolve(p.always === true ? "always" : p.allow === true);
192
194
  return reply(rpcResult(id, {})); // idempotent — a late/duplicate reply is a no-op
193
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
+ }
194
212
  default:
195
213
  return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
196
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.114.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"