@nanhara/hara 0.103.0 → 0.105.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,32 @@ 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.105.0 — fan-outs synthesize before acting
9
+
10
+ - **Synthesis nudge** (the last adopted item from the Claude Code internals study — their KN5
11
+ synthesizer, hara-shaped): when a round returns **3+ parallel agent reports**, a silent
12
+ system-reminder tells the model to merge them first — reconcile overlaps and conflicts explicitly,
13
+ note what only one report saw, state the merged conclusion — instead of anchoring on whichever
14
+ report happens to sit last in context. Rides the 0.100.0 reminder layer; no new machinery.
15
+
16
+ ## 0.104.0 — compaction keeps your working files + honest context accounting
17
+
18
+ Closes the last two adopted items from the Claude Code internals study, and un-breaks the release
19
+ pipeline.
20
+
21
+ - **Post-compaction file restore** (CC's TW5): compaction now re-attaches the CURRENT on-disk content
22
+ of the files the conversation was most recently working with (top 5, byte-capped) — the summary is
23
+ no longer the model's only anchor, so it doesn't re-read its own working set or act on a stale
24
+ memory of an edited file.
25
+ - **Context threshold ladder + cache-aware accounting**: the footer's `ctx N%` turns yellow at 60%
26
+ and red at 80% (auto-compact fires at 85), so compaction never surprises you. And on Anthropic
27
+ endpoints, input accounting now includes cache reads/writes — cached sessions used to under-report
28
+ context fullness so badly that auto-compact could never fire before overflow.
29
+ - **Release pipeline fixed** (every `v*` tag's release workflow had been failing silently):
30
+ the Docker image's `npm ci` ran the `prepare → tsc` hook before src was copied; and the standalone
31
+ binaries died on the Feishu SDK's default import under bun's ESM resolution. Both corrected —
32
+ binaries + ghcr image ship again from this tag.
33
+
8
34
  ## 0.103.0 — the project-analysis SOP (why hara felt slow on "analyze this repo")
9
35
 
10
36
  The execution layer could always parallelize reads and fan out read-only sub-agents — but nothing
@@ -20,6 +20,28 @@ export const COMPACT_SYSTEM = "Summarize the conversation so far into a structur
20
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
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
22
  "Be specific and concrete. Drop the <analysis>; output only the headed summary.";
23
+ /** Post-compaction file restore (Claude Code's TW5): re-attach the CURRENT content of the files the
24
+ * conversation was just working with, so the summary isn't the model's only anchor — it doesn't have
25
+ * to re-read its own working set next turn (and can't act on a stale memory of an edited file).
26
+ * `readFn` is injected (returns null for unreadable/gone files); byte caps bound the token cost. */
27
+ export function buildFileRestore(paths, readFn, opts) {
28
+ const per = opts?.perFileBytes ?? 8_192;
29
+ let budget = opts?.totalBytes ?? 24_576;
30
+ const parts = [];
31
+ for (const p of paths) {
32
+ if (budget <= 0)
33
+ break;
34
+ const raw = readFn(p);
35
+ if (raw == null)
36
+ continue;
37
+ const clipped = raw.slice(0, Math.min(per, budget));
38
+ budget -= clipped.length;
39
+ parts.push(`--- ${p}${clipped.length < raw.length ? " (truncated)" : ""} ---\n${clipped}`);
40
+ }
41
+ if (!parts.length)
42
+ return null;
43
+ return `Files you were recently working with (CURRENT on-disk content, restored after compaction):\n\n${parts.join("\n\n")}`;
44
+ }
23
45
  /** Whether to auto-compact now: enabled, the history is substantial enough to be worth summarizing, and the
24
46
  * last turn filled the context past the threshold (so the NEXT turn would risk overflow). */
25
47
  export function shouldAutoCompact(ctxPct, historyLen, autoCompact, threshold = AUTO_COMPACT_PCT) {
@@ -11,8 +11,12 @@ import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock }
11
11
  import { subdirHint } from "../context/subdir-hints.js";
12
12
  import { classifyError, failoverAction, errorHint } from "./failover.js";
13
13
  import { currentTodos, renderTodos } from "../tools/todo.js";
14
- import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_STALE_ROUNDS } from "./reminders.js";
14
+ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_STALE_ROUNDS, synthesisReminder, SYNTHESIS_MIN_AGENTS } from "./reminders.js";
15
15
  import { setTurnPhase } from "./phase.js";
16
+ import { recordTouch } from "./touched.js";
17
+ import { resolve as resolvePath } from "node:path";
18
+ /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
19
+ const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
16
20
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
17
21
  * stalled connection and aborted into the normal error→failover path — instead of hanging on
18
22
  * "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
@@ -381,6 +385,11 @@ export async function runAgent(history, opts) {
381
385
  results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
382
386
  return;
383
387
  }
388
+ // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
389
+ // sub-agents read broadly — their files aren't "what the user was working on").
390
+ if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
391
+ recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)));
392
+ }
384
393
  const res = await p.tool.run(p.tu.input, ctx);
385
394
  // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
386
395
  results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) };
@@ -413,6 +422,14 @@ export async function runAgent(history, opts) {
413
422
  }
414
423
  await flush();
415
424
  history.push({ role: "tool", results });
425
+ // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
426
+ // produced N independent reports — remind the model to merge/reconcile them before acting, instead
427
+ // of anchoring on whichever report happens to sit last in context.
428
+ if (!opts.quiet) {
429
+ const fanout = r.toolUses.filter((tu) => tu.name === "agent").length;
430
+ if (fanout >= SYNTHESIS_MIN_AGENTS)
431
+ pushReminder(synthesisReminder(fanout));
432
+ }
416
433
  // Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
417
434
  // unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
418
435
  // re-showing the authoritative list (then the counter re-arms — at most one nag per N rounds).
@@ -30,6 +30,15 @@ export function wrapReminders(items) {
30
30
  /** How many tool rounds a checklist may sit untouched (with unfinished items) before the model gets an
31
31
  * attention refresh. Reset on every todo_write; re-arms after firing so it nags at most once per N. */
32
32
  export const TODO_STALE_ROUNDS = 5;
33
+ /** Parallel fan-outs at/above this size get a synthesis nudge (CC's KN5 synthesizer, hara-shaped:
34
+ * instead of a dedicated merger agent, the MAIN model is reminded to merge before acting). */
35
+ export const SYNTHESIS_MIN_AGENTS = 3;
36
+ /** The synthesis nudge: N independent reports just landed — reconcile before acting. */
37
+ export function synthesisReminder(n) {
38
+ return (`You just received ${n} parallel agent reports. Before acting, SYNTHESIZE them into one coherent ` +
39
+ "picture: reconcile overlaps and conflicts explicitly (say which report wins and why), note anything " +
40
+ "only one report saw, and state the merged conclusion. Don't act on a single report in isolation.");
41
+ }
33
42
  /** The staleness nudge: re-show the authoritative list + ask for a status pass. */
34
43
  export function todoStaleReminder(renderedTodos) {
35
44
  return (`Your todo list has not been updated in a while. Current state:\n\n${renderedTodos}\n\n` +
@@ -0,0 +1,18 @@
1
+ // Recently-touched file tracker — feeds post-compaction FILE RESTORE (Claude Code's TW5 pattern).
2
+ // The loop records every file the MAIN conversation reads/edits; when the history is compacted to a
3
+ // summary, the top-N most-recent files get their CURRENT on-disk content re-attached — so the model
4
+ // doesn't lose the very files it was working on and re-read them all next turn.
5
+ const touched = new Map(); // absolute path → last-touch timestamp
6
+ export function recordTouch(path) {
7
+ touched.set(path, Date.now());
8
+ }
9
+ /** Most-recently-touched first. */
10
+ export function recentTouched(n = 5) {
11
+ return [...touched.entries()]
12
+ .sort((a, b) => b[1] - a[1])
13
+ .slice(0, n)
14
+ .map(([p]) => p);
15
+ }
16
+ export function clearTouched() {
17
+ touched.clear();
18
+ }
@@ -3,7 +3,11 @@
3
3
  // outbound. Creds from HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET (+ HARA_FEISHU_DOMAIN=lark for larksuite.com).
4
4
  // Same ChatAdapter shape as the others, so all cross-platform gateway logic (send_file, system context,
5
5
  // stuck-guard, image attach/describe) works unchanged. v1 = p2p (DM) only; group support is a fast-follow.
6
- import lark from "@larksuiteoapi/node-sdk";
6
+ // Namespace import + default fallback: node resolves this SDK as CJS (default = module object), but
7
+ // bun's bundler resolves its ESM build (named exports only, NO default) — `import lark from` made
8
+ // every binaries release fail. This form works under both resolutions.
9
+ import * as larkNs from "@larksuiteoapi/node-sdk";
10
+ const lark = (larkNs.default ?? larkNs);
7
11
  import { createReadStream, createWriteStream, mkdirSync } from "node:fs";
8
12
  import { pipeline } from "node:stream/promises";
9
13
  import { join, basename } from "node:path";
package/dist/index.js CHANGED
@@ -24,7 +24,8 @@ 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, COMPACT_SYSTEM } from "./agent/compact.js";
27
+ import { shouldAutoCompact, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
28
+ import { recentTouched } from "./agent/touched.js";
28
29
  import { checkForUpdate } from "./update-check.js";
29
30
  import { formatContextReport } from "./agent/context-report.js";
30
31
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
@@ -744,6 +745,18 @@ async function compactConversation(provider, history, meta, stats) {
744
745
  meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects into the next turns
745
746
  history.length = 0;
746
747
  history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
748
+ // TW5-style file restore: the summary alone loses the working set's ACTUAL content — re-attach the
749
+ // most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
750
+ const restore = buildFileRestore(recentTouched(5), (p) => {
751
+ try {
752
+ return readFileSync(p, "utf8");
753
+ }
754
+ catch {
755
+ return null;
756
+ }
757
+ });
758
+ if (restore)
759
+ history.push({ role: "user", content: restore });
747
760
  stats.input += r.usage?.input ?? 0;
748
761
  stats.output += r.usage?.output ?? 0;
749
762
  stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary, not the old full turn
@@ -113,7 +113,11 @@ export function createAnthropicProvider(opts) {
113
113
  .filter((b) => b.type === "tool_use")
114
114
  .map((b) => ({ id: b.id, name: b.name, input: b.input }));
115
115
  const stop = msg.stop_reason === "tool_use" ? "tool_use" : "end";
116
- const usage = { input: msg.usage?.input_tokens ?? 0, output: msg.usage?.output_tokens ?? 0 };
116
+ // Cache-aware input accounting: Anthropic's input_tokens EXCLUDES cache reads/writes, so a
117
+ // cached session under-reported context fullness badly (ctx% stayed tiny → auto-compact never
118
+ // fired → overflow). Total context = fresh + cache_creation + cache_read (CC's zY5 equivalent).
119
+ const u = msg.usage;
120
+ const usage = { input: (u?.input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0) + (u?.cache_read_input_tokens ?? 0), output: u?.output_tokens ?? 0 };
117
121
  return { text, toolUses, stop, usage };
118
122
  },
119
123
  };
@@ -54,21 +54,25 @@ export function footerParts(model, s, cwdShort, route) {
54
54
  return {
55
55
  prefix: ` ${model} · `,
56
56
  mode: s.approval,
57
- suffix: `${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`,
57
+ suffix: `${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · `,
58
+ ctx: `ctx ${s.ctxPct}%`,
59
+ // Claude Code's threshold ladder (60 warn / 80 error / 92 compact): hara auto-compacts at 85, so
60
+ // the footer escalates BEFORE that — yellow at 60, red at 80 — and the user sees compaction coming.
61
+ ctxLevel: s.ctxPct >= 80 ? "high" : s.ctxPct >= 60 ? "warn" : "ok",
58
62
  };
59
63
  }
60
- /** Back-compat: the full footer as one string (prefix+mode+suffix). Kept for any pure consumer/test. */
64
+ /** Back-compat: the full footer as one string. Kept for any pure consumer/test. */
61
65
  export function footerLine(model, s, cwdShort, route) {
62
66
  const p = footerParts(model, s, cwdShort, route);
63
- return p.prefix + p.mode + p.suffix;
67
+ return p.prefix + p.mode + p.suffix + p.ctx;
64
68
  }
65
69
  // The merged status footer: model · approval · route · cwd · usage · ctx. The active approval mode is
66
70
  // colored inline (the always-on ModeBar is gone — shift+tab now pops a transient selector instead), so
67
71
  // the outer <Text> is NOT dim: only the prefix/suffix are dimmed and the mode token stays bright.
68
72
  // Memoized so a prompt keystroke doesn't reconcile it.
69
73
  const Footer = memo(function Footer({ model, s, cwdShort, route }) {
70
- const { prefix, mode, suffix } = footerParts(model, s, cwdShort, route);
71
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: prefix }), _jsx(Text, { color: approvalColor(s.approval), bold: true, children: mode }), _jsx(Text, { dimColor: true, children: suffix })] }));
74
+ const { prefix, mode, suffix, ctx, ctxLevel } = footerParts(model, s, cwdShort, route);
75
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: prefix }), _jsx(Text, { color: approvalColor(s.approval), bold: true, children: mode }), _jsx(Text, { dimColor: true, children: suffix }), ctxLevel === "ok" ? _jsx(Text, { dimColor: true, children: ctx }) : _jsx(Text, { color: ctxLevel === "high" ? "red" : "yellow", bold: true, children: ctx })] }));
72
76
  });
73
77
  // The rounded TOP edge of the input box, carrying the session name in the right corner (it "rides"
74
78
  // the border — codex-style titled panel, and where hara has always shown it). Drawn by hand because
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.103.0",
3
+ "version": "0.105.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"