@nanhara/hara 0.99.3 → 0.100.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,25 @@ 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.100.0 — the agent keeps its own attention: system-reminders + anti-drift compaction
9
+
10
+ Distilled from a source-level study of Claude Code v1.0.33's agent internals (the Ie1/WD5 reminder
11
+ layer and the AU2 compaction template).
12
+
13
+ - **system-reminder injection layer.** An event queue (`agent/reminders.ts`) that lands queued context
14
+ as ONE `<system-reminder>`-wrapped message before the next model call — visible to the model, never
15
+ rendered in the transcript, and always carrying the "ignore unless relevant" disclaimer so a nudge
16
+ can't derail unrelated work. Quiet (sub-agent) runs neither drain nor push, so parallel fan-outs
17
+ can't steal the main conversation's reminders. First wired event:
18
+ - **Todo attention-refresh.** When a checklist has unfinished items and goes 5 tool-rounds untouched,
19
+ the model gets a reminder re-showing the authoritative list and asking for a status pass — so long
20
+ tasks stop silently abandoning their own plan. Any `todo_write` resets the clock; after firing it
21
+ re-arms (at most one nag per 5 rounds).
22
+ - **Compaction brief: 6 → 8 sections (anti-drift).** `/compact` and auto-compaction now also preserve
23
+ **All user messages** — your own words survive verbatim, in order, however hard the history is
24
+ squeezed — and **Key technical concepts**, so the next turn doesn't re-derive the stack.
25
+ (`COMPACT_SYSTEM` moved to `agent/compact.ts` where tests pin the structure.)
26
+
8
27
  ## 0.99.3 — TUI: rock-steady input box (constant-height chrome) + plan mode grows a real handshake
9
28
 
10
29
  Built from a source-level study of codex-rs (bottom-anchored viewport, plan cells) and Claude Code
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  **Highlights**
14
14
  - **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
15
15
  - **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images** (send a photo → it sees it; ask for a file → it sends one), per-chat resumable sessions, and project roaming. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
16
- - **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only → propose a plan → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
16
+ - **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only investigation the model submits its plan via `exit_plan` → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
17
17
  - **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
18
18
  - **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
19
19
  - **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. Gated by approval, trust-tiered, and never exposed to read-only sub-agents.
@@ -3,6 +3,23 @@
3
3
  // which reuses the manual /compact path; this just decides *when* to fire.
4
4
  /** Auto-compact once the last turn used ≥ this % of the model's context window. */
5
5
  export const AUTO_COMPACT_PCT = 85;
6
+ /** The compaction brief (shared by /compact and auto-compaction). Eight sections, mirroring Claude
7
+ * Code's AU2 template — the two that matter most beyond the obvious: **All user messages** (the
8
+ * user's own words survive verbatim, so however hard the history is squeezed, intent never drifts)
9
+ * and **Key technical concepts** (the frameworks/APIs in play, so the next turn doesn't re-derive
10
+ * the stack). Lives here (not index.ts) so tests can pin the structure. */
11
+ export const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
12
+ "loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
13
+ "then output ONLY the summary under these exact headings:\n" +
14
+ "1. Goal — the user's overall intent, in their own framing.\n" +
15
+ "2. Key technical concepts — the frameworks, tools, APIs, and domain ideas central to the work.\n" +
16
+ "3. Key decisions — choices made and why (so they aren't relitigated).\n" +
17
+ "4. Files & code — files created/changed and the important snippets, with why each matters.\n" +
18
+ "5. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
19
+ "6. Current state — what works now / what is verified.\n" +
20
+ "7. All user messages — EVERY user message so far (excluding tool results), verbatim and in order; abbreviate only huge pasted blobs with […]. These are the ground truth of intent.\n" +
21
+ "8. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
22
+ "Be specific and concrete. Drop the <analysis>; output only the headed summary.";
6
23
  /** Whether to auto-compact now: enabled, the history is substantial enough to be worth summarizing, and the
7
24
  * last turn filled the context past the threshold (so the NEXT turn would risk overflow). */
8
25
  export function shouldAutoCompact(ctxPct, historyLen, autoCompact, threshold = AUTO_COMPACT_PCT) {
@@ -10,7 +10,8 @@ import { decideCommand, loadPermissionRules } from "../security/permissions.js";
10
10
  import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
11
11
  import { subdirHint } from "../context/subdir-hints.js";
12
12
  import { classifyError, failoverAction, errorHint } from "./failover.js";
13
- import { currentTodos } from "../tools/todo.js";
13
+ import { currentTodos, renderTodos } from "../tools/todo.js";
14
+ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_STALE_ROUNDS } from "./reminders.js";
14
15
  /** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
15
16
  * surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
16
17
  * instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
@@ -86,6 +87,9 @@ export async function runAgent(history, opts) {
86
87
  const guardianOn = !!opts.guardian && (opts.guardian.enabled ?? true) && guardianEnabled();
87
88
  const breaker = newBreaker();
88
89
  let breakerHalt = false; // set when a tripped breaker aborts this run
90
+ // Todo attention-refresh (à la Claude Code): tool rounds since the checklist was last touched while
91
+ // unfinished items exist. Main loop only — quiet (sub-agent) runs share the global list and must not nag.
92
+ let todoIdleRounds = 0;
89
93
  for (;;) {
90
94
  // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
91
95
  // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
@@ -93,6 +97,14 @@ export async function runAgent(history, opts) {
93
97
  for (const m of await opts.pendingInput())
94
98
  history.push(m);
95
99
  }
100
+ // system-reminder injection: event-driven context queued since the last call (todo staleness today)
101
+ // lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
102
+ // sub-agent must not steal the main conversation's reminders.
103
+ if (!opts.quiet) {
104
+ const reminders = drainReminders();
105
+ if (reminders.length)
106
+ history.push({ role: "user", content: wrapReminders(reminders) });
107
+ }
96
108
  const baseSpecs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
97
109
  const specs = opts.extraTools?.length
98
110
  ? [...baseSpecs, ...opts.extraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
@@ -349,6 +361,21 @@ export async function runAgent(history, opts) {
349
361
  }
350
362
  await flush();
351
363
  history.push({ role: "tool", results });
364
+ // Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
365
+ // unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
366
+ // re-showing the authoritative list (then the counter re-arms — at most one nag per N rounds).
367
+ if (!opts.quiet) {
368
+ if (r.toolUses.some((tu) => tu.name === "todo_write")) {
369
+ todoIdleRounds = 0;
370
+ }
371
+ else if (currentTodos().some((t) => t.status !== "done")) {
372
+ todoIdleRounds++;
373
+ if (todoIdleRounds >= TODO_STALE_ROUNDS) {
374
+ pushReminder(todoStaleReminder(renderTodos(currentTodos())));
375
+ todoIdleRounds = 0;
376
+ }
377
+ }
378
+ }
352
379
  if (breakerHalt) {
353
380
  // A tripped-and-declined circuit-breaker is a hard stop: end the run cleanly (the denial messages are
354
381
  // already in `results` so the model/user see why). Never spin further.
@@ -0,0 +1,38 @@
1
+ // system-reminder injection (à la Claude Code's Ie1/WD5 event layer): event-driven context the model
2
+ // should see on its NEXT call, injected as ONE `<system-reminder>`-wrapped user message. It rides the
3
+ // provider history only — the UI transcript never renders it, so the user isn't bothered while the
4
+ // model stays synchronized with system state (todo staleness today; file-change/diagnostic events can
5
+ // plug in later via pushReminder).
6
+ //
7
+ // Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
8
+ // injected nudge never derails an unrelated task.
9
+ const queue = [];
10
+ /** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
11
+ * neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
12
+ export function pushReminder(text) {
13
+ const t = text.trim();
14
+ if (t)
15
+ queue.push(t);
16
+ }
17
+ /** Take everything queued (FIFO), clearing the queue. */
18
+ export function drainReminders() {
19
+ if (!queue.length)
20
+ return [];
21
+ return queue.splice(0, queue.length);
22
+ }
23
+ /** Merge queued reminders into the single injected message. */
24
+ export function wrapReminders(items) {
25
+ return ("<system-reminder>\n" +
26
+ items.join("\n\n") +
27
+ "\n\nThis context may or may not be relevant to your task — do not respond to it directly; ignore it unless it is relevant.\n" +
28
+ "</system-reminder>");
29
+ }
30
+ /** How many tool rounds a checklist may sit untouched (with unfinished items) before the model gets an
31
+ * attention refresh. Reset on every todo_write; re-arms after firing so it nags at most once per N. */
32
+ export const TODO_STALE_ROUNDS = 5;
33
+ /** The staleness nudge: re-show the authoritative list + ask for a status pass. */
34
+ export function todoStaleReminder(renderedTodos) {
35
+ return (`Your todo list has not been updated in a while. Current state:\n\n${renderedTodos}\n\n` +
36
+ "If you have completed or started items, update them with todo_write now (statuses drive the user's progress view). " +
37
+ "If the list no longer matches the work, rewrite it.");
38
+ }
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
24
24
  import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
25
25
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
26
  import { routingProvider } from "./agent/route.js";
27
- import { shouldAutoCompact } from "./agent/compact.js";
27
+ import { shouldAutoCompact, COMPACT_SYSTEM } from "./agent/compact.js";
28
28
  import { formatContextReport } from "./agent/context-report.js";
29
29
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
30
30
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
@@ -716,16 +716,7 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
716
716
  "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
717
717
  "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
718
718
  "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
719
- const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
720
- "loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
721
- "then output ONLY the summary under these exact headings:\n" +
722
- "1. Goal — the user's overall intent, in their own framing.\n" +
723
- "2. Key decisions — choices made and why (so they aren't relitigated).\n" +
724
- "3. Files & code — files created/changed and the important snippets, with why each matters.\n" +
725
- "4. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
726
- "5. Current state — what works now / what is verified.\n" +
727
- "6. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
728
- "Be specific and concrete. Drop the <analysis>; output only the headed summary.";
719
+ // COMPACT_SYSTEM (the 8-section brief) lives in agent/compact.ts so tests can pin its structure.
729
720
  const workingSetFromSummary = (s) => s
730
721
  .split("\n")
731
722
  .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
package/dist/org/roles.js CHANGED
@@ -22,13 +22,37 @@ export function orgRolesDir() {
22
22
  export function claudeAgentsDir(cwd) {
23
23
  return join(findProjectRoot(cwd), ".claude", "agents");
24
24
  }
25
- /** Accept Claude-Code `tools:` (comma string or list) as an alias for hara's allowTools. */
26
- function claudeTools(v) {
27
- if (Array.isArray(v))
28
- return v;
29
- if (typeof v === "string" && v.trim())
30
- return v.split(",").map((s) => s.trim()).filter(Boolean);
31
- return undefined;
25
+ /** Claude-Code tool names hara tool names, for `.claude/agents` interop. Without this, a CC agent
26
+ * with `tools: Read, Edit, Bash` produced allowTools that matched ZERO hara tools — the role spawned
27
+ * with an empty toolbox. Unknown names pass through verbatim (they may be hara names already). */
28
+ const CLAUDE_TOOL_MAP = {
29
+ read: "read_file",
30
+ edit: "edit_file",
31
+ write: "write_file",
32
+ bash: "bash",
33
+ grep: "grep",
34
+ glob: "glob",
35
+ ls: "ls",
36
+ webfetch: "web_fetch",
37
+ websearch: "web_search",
38
+ agent: "agent",
39
+ task: "agent",
40
+ todowrite: "todo_write",
41
+ notebookedit: "edit_file",
42
+ };
43
+ /** Accept Claude-Code `tools:` (comma string or list) as an alias for hara's allowTools —
44
+ * translating CC tool names to hara's. "All tools" / "*" means unrestricted → undefined. */
45
+ export function claudeTools(v) {
46
+ const raw = Array.isArray(v)
47
+ ? v
48
+ : typeof v === "string" && v.trim()
49
+ ? v.split(",").map((s) => s.trim()).filter(Boolean)
50
+ : null;
51
+ if (!raw || !raw.length)
52
+ return undefined;
53
+ if (raw.some((t) => /^(\*|all tools?)$/i.test(t)))
54
+ return undefined; // unrestricted
55
+ return raw.map((t) => CLAUDE_TOOL_MAP[t.toLowerCase().replace(/[^a-z]/g, "")] ?? t);
32
56
  }
33
57
  function parseFrontmatter(text) {
34
58
  const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
@@ -83,7 +107,9 @@ export function loadRoles(cwd) {
83
107
  description: fm.description || "",
84
108
  owns: Array.isArray(fm.owns) ? fm.owns : [],
85
109
  rejects: Array.isArray(fm.rejects) ? fm.rejects : [],
86
- model: fm.model || undefined,
110
+ // Claude-Code model ALIASES (sonnet/opus/haiku/inherit) aren't hara model ids — treat as
111
+ // "inherit the session model" rather than passing a string no provider resolves.
112
+ model: fm.model && !/^(sonnet|opus|haiku|inherit)$/i.test(String(fm.model)) ? fm.model : undefined,
87
113
  allowTools: Array.isArray(fm.allowTools) ? fm.allowTools : claudeTools(fm.tools),
88
114
  denyTools: Array.isArray(fm.denyTools) ? fm.denyTools : undefined,
89
115
  system: body,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.99.3",
3
+ "version": "0.100.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"