@nanhara/hara 0.104.0 → 0.106.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 +16 -0
- package/dist/agent/loop.js +9 -1
- package/dist/agent/reminders.js +9 -0
- package/dist/gateway/serve.js +4 -0
- package/dist/gateway/sessions.js +24 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.106.0 — gateway session hygiene
|
|
9
|
+
|
|
10
|
+
- **Idle chats auto-rotate to a fresh thread.** A WeChat/Feishu chat is one endless surface — days-old
|
|
11
|
+
context used to pile onto every new ask and the agent answered from stale state. Now a chat idle
|
|
12
|
+
past **8 hours** (HARA_GATEWAY_IDLE_HOURS to tune; 0 disables) starts the next message on a fresh
|
|
13
|
+
session, with a one-time notice carrying `/resume <id>` — the old thread persists, nothing is lost.
|
|
14
|
+
Same-afternoon follow-ups continue as before.
|
|
15
|
+
|
|
16
|
+
## 0.105.0 — fan-outs synthesize before acting
|
|
17
|
+
|
|
18
|
+
- **Synthesis nudge** (the last adopted item from the Claude Code internals study — their KN5
|
|
19
|
+
synthesizer, hara-shaped): when a round returns **3+ parallel agent reports**, a silent
|
|
20
|
+
system-reminder tells the model to merge them first — reconcile overlaps and conflicts explicitly,
|
|
21
|
+
note what only one report saw, state the merged conclusion — instead of anchoring on whichever
|
|
22
|
+
report happens to sit last in context. Rides the 0.100.0 reminder layer; no new machinery.
|
|
23
|
+
|
|
8
24
|
## 0.104.0 — compaction keeps your working files + honest context accounting
|
|
9
25
|
|
|
10
26
|
Closes the last two adopted items from the Claude Code internals study, and un-breaks the release
|
package/dist/agent/loop.js
CHANGED
|
@@ -11,7 +11,7 @@ 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
16
|
import { recordTouch } from "./touched.js";
|
|
17
17
|
import { resolve as resolvePath } from "node:path";
|
|
@@ -422,6 +422,14 @@ export async function runAgent(history, opts) {
|
|
|
422
422
|
}
|
|
423
423
|
await flush();
|
|
424
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
|
+
}
|
|
425
433
|
// Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
|
|
426
434
|
// unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
|
|
427
435
|
// re-showing the authoritative list (then the counter re-arms — at most one nag per N rounds).
|
package/dist/agent/reminders.js
CHANGED
|
@@ -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` +
|
package/dist/gateway/serve.js
CHANGED
|
@@ -245,6 +245,10 @@ export async function runGateway(opts) {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
const ctx = chatContext(adapter.name, m.chatId, cwd); // this chat's current { cwd, sessionId }
|
|
248
|
+
if (ctx.rotatedFrom) {
|
|
249
|
+
// Idle auto-rotation just happened (session hygiene): tell the user ONCE, with the escape hatch.
|
|
250
|
+
await adapter.send(m.chatId, `🧵 fresh thread (chat was idle) — /resume ${ctx.rotatedFrom.slice(-18)} continues the previous one`);
|
|
251
|
+
}
|
|
248
252
|
const cmd = parseCommand(m.text);
|
|
249
253
|
if (cmd) {
|
|
250
254
|
if (cmd.cmd === "help")
|
package/dist/gateway/sessions.js
CHANGED
|
@@ -8,6 +8,16 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
+
/** Idle window before a chat auto-rotates to a FRESH session (hours). A WeChat/Feishu chat is one
|
|
12
|
+
* endless surface — without this, days-old context piles onto every new ask and the agent answers
|
|
13
|
+
* from stale state (the "gateway answers with old context" report). Default 8h: overnight gaps start
|
|
14
|
+
* clean, a same-afternoon follow-up continues. HARA_GATEWAY_IDLE_HOURS tunes it; 0 disables. */
|
|
15
|
+
export function idleRotationMs() {
|
|
16
|
+
const raw = Number(process.env.HARA_GATEWAY_IDLE_HOURS ?? 8);
|
|
17
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
18
|
+
return 0; // 0/garbage → disabled
|
|
19
|
+
return raw * 3_600_000;
|
|
20
|
+
}
|
|
11
21
|
const dir = () => join(homedir(), ".hara", "gateway");
|
|
12
22
|
const file = () => join(dir(), "chats.json");
|
|
13
23
|
const mapKey = (platform, chatId) => `${platform}:${chatId}`;
|
|
@@ -37,17 +47,27 @@ export function chatContext(platform, chatId, defaultCwd) {
|
|
|
37
47
|
const m = load();
|
|
38
48
|
const k = mapKey(platform, chatId);
|
|
39
49
|
const e = m[k];
|
|
50
|
+
const now = Date.now();
|
|
40
51
|
if (!e) {
|
|
41
|
-
const fresh = { cwd: defaultCwd, sessionId: deriveId(platform, chatId, defaultCwd, 0), fork: 0 };
|
|
52
|
+
const fresh = { cwd: defaultCwd, sessionId: deriveId(platform, chatId, defaultCwd, 0), fork: 0, lastUsed: now };
|
|
42
53
|
m[k] = fresh;
|
|
43
54
|
save(m);
|
|
44
55
|
return { cwd: fresh.cwd, sessionId: fresh.sessionId, voice: false };
|
|
45
56
|
}
|
|
46
|
-
if (!e.cwd)
|
|
57
|
+
if (!e.cwd)
|
|
47
58
|
e.cwd = defaultCwd; // migrate an old (pre-cwd) entry, preserving its existing sessionId
|
|
48
|
-
|
|
59
|
+
// Session hygiene: a chat idle past the window rotates to a fresh thread (same mechanics as /new).
|
|
60
|
+
// The OLD id is returned so the gateway can offer "/resume <id>" — nothing is lost, sessions persist.
|
|
61
|
+
const idleMs = idleRotationMs();
|
|
62
|
+
let rotatedFrom;
|
|
63
|
+
if (idleMs > 0 && typeof e.lastUsed === "number" && now - e.lastUsed > idleMs && e.sessionId) {
|
|
64
|
+
rotatedFrom = e.sessionId;
|
|
65
|
+
e.fork = (e.fork ?? 0) + 1;
|
|
66
|
+
e.sessionId = deriveId(platform, chatId, e.cwd, e.fork);
|
|
49
67
|
}
|
|
50
|
-
|
|
68
|
+
e.lastUsed = now;
|
|
69
|
+
save(m);
|
|
70
|
+
return { cwd: e.cwd, sessionId: e.sessionId, voice: !!e.voice, ...(rotatedFrom ? { rotatedFrom } : {}) };
|
|
51
71
|
}
|
|
52
72
|
/** `/voice` — toggle whether this chat's replies are spoken (TTS audio); returns the new state. */
|
|
53
73
|
export function toggleVoice(platform, chatId) {
|