@inceptionstack/roundhouse 0.5.25 → 0.5.27

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
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `@inceptionstack/roundhouse` are documented here.
4
4
 
5
+ ## [0.5.27] — 2026-05-14
6
+
7
+ ### Fixed
8
+ - **Self-update no longer falsely fails on mise/nvm hosts** — on systems where Node is managed by mise (or nvm), `npm install -g` triggers a post-install reshim hook that exits 127 when its tool isn't on PATH, causing `execSync` to throw even though the package was written to disk correctly. The user-visible bug: "Self-update failed: Command failed: npm install -g …" plus `/status` continuing to show the old version forever (because the gateway never restarted). Fix: when the install command throws, consult `npm list -g <pkg>` and trust the on-disk version. If it matches the target, treat the install as successful. Same logic applied to extension updates. (#128)
9
+ - **Side effect:** `/update` now fires its existing 'restarting…' branch on this case, so `/status` reflects the new version on next boot.
10
+
11
+ ### Changed
12
+ - **DRY in `cli/update.ts`:** extracted `getInstalledVersion()` helper used by both pre-install version check and post-failure verification; introduced `SELF_PACKAGE` constant; fixed stale `commands/update.ts` header comment.
13
+
14
+ ## [0.5.26] — 2026-05-14
15
+
16
+ ### Fixed
17
+ - **Emergency compact loop — output-cap mismatch + summarization input overflow.** Two compounding bugs caused infinite emergency-compact loops on Haiku 4.5 sessions near the context limit. (1) `reserveTokens=150000` + Haiku's 64k output cap produced `maxTokens=120000`, which Bedrock rejected. (2) `hardTokens=200k`/`softTokens=180k` against a 200k window left no headroom for the summarizer prompt itself. Fix: lower thresholds to 150k/130k, add `COMPACT_HEADROOM_TOKENS=50k`, force `thinkingLevel:off` in `compactWithModel`, drop `reserveTokens` to 78k. State is now loaded once and reused; phase timing is hoisted; telemetry is accurate on failure. (#126)
18
+
5
19
  ## [0.5.25] — 2026-05-12
6
20
 
7
21
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.25",
3
+ "version": "0.5.27",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
package/src/cli/update.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * commands/update.ts — Handle the /update command
2
+ * cli/update.ts — Handle the /update command
3
3
  *
4
4
  * Transport-agnostic: receives a ProgressReporter interface,
5
5
  * not a Telegram-specific thread object.
@@ -15,6 +15,28 @@ const GLOBAL_PI_EXTENSION_PACKAGES = [
15
15
  "@inceptionstack/pi-branch-enforcer",
16
16
  ];
17
17
 
18
+ const SELF_PACKAGE = "@inceptionstack/roundhouse";
19
+
20
+ /**
21
+ * Read globally-installed version of a package from disk.
22
+ * Returns empty string if the package is not installed or query fails.
23
+ *
24
+ * Used both for pre-install version checks and for post-failure verification
25
+ * (mise/nvm/npm reshim hooks can fail with exit 127 even after `npm install -g`
26
+ * actually wrote the new version to disk — see PR fix/self-update-verify-on-failure).
27
+ */
28
+ function getInstalledVersion(pkg: string): string {
29
+ try {
30
+ const out = execSync(`npm list -g ${pkg} --json --depth=0 2>/dev/null`, {
31
+ timeout: 10_000,
32
+ encoding: "utf8",
33
+ });
34
+ return JSON.parse(out)?.dependencies?.[pkg]?.version ?? "";
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
18
40
  export interface UpdateProgress {
19
41
  update(text: string): Promise<void>;
20
42
  }
@@ -28,14 +50,11 @@ export interface UpdateResult {
28
50
 
29
51
  export async function updateExtensions(progress: UpdateProgress): Promise<void> {
30
52
  for (const extensionPackage of GLOBAL_PI_EXTENSION_PACKAGES) {
53
+ let latestExtVersion = "";
31
54
  try {
32
55
  // Check if already at latest
33
- const installed = execSync(`npm list -g ${extensionPackage} --json 2>/dev/null`, {
34
- timeout: 10_000,
35
- encoding: "utf8",
36
- });
37
- const installedVersion = JSON.parse(installed)?.dependencies?.[extensionPackage]?.version ?? "";
38
- const latestExtVersion = execSync(`npm view ${extensionPackage} version 2>/dev/null`, {
56
+ const installedVersion = getInstalledVersion(extensionPackage);
57
+ latestExtVersion = execSync(`npm view ${extensionPackage} version 2>/dev/null`, {
39
58
  timeout: 10_000,
40
59
  encoding: "utf8",
41
60
  }).trim();
@@ -57,6 +76,14 @@ export async function updateExtensions(progress: UpdateProgress): Promise<void>
57
76
  await progress.update(`✅ ${extensionPackage} updated`);
58
77
  } catch (e) {
59
78
  const msg = e instanceof Error ? e.message : String(e);
79
+ // Verify-after-fail: post-install reshim hooks (mise/nvm) can exit non-zero
80
+ // even when the package landed on disk correctly.
81
+ const onDisk = getInstalledVersion(extensionPackage);
82
+ if (onDisk && (!latestExtVersion || onDisk === latestExtVersion)) {
83
+ console.warn(`[roundhouse] ${extensionPackage} install reported failure but v${onDisk} is on disk — treating as success:`, msg);
84
+ await progress.update(`✅ ${extensionPackage} updated to v${onDisk} (post-install hook warned, ignored)`);
85
+ continue;
86
+ }
60
87
  console.warn(`[roundhouse] failed to update extension ${extensionPackage}:`, msg);
61
88
  await progress.update(`⚠️ Failed to update ${extensionPackage}: ${msg.slice(0, 150)}`);
62
89
  }
@@ -71,13 +98,20 @@ export async function updateSelf(
71
98
  await progress.update(`📦 Updating v${currentVersion} → v${latestVersion}...`);
72
99
 
73
100
  try {
74
- execSync("npm install -g @inceptionstack/roundhouse@latest 2>&1", {
101
+ execSync(`npm install -g ${SELF_PACKAGE}@latest 2>&1`, {
75
102
  timeout: 120_000,
76
103
  encoding: "utf8",
77
104
  });
78
105
  return undefined;
79
106
  } catch (e) {
80
107
  const msg = e instanceof Error ? e.message : String(e);
108
+ // Verify-after-fail: mise/nvm post-install reshim can exit 127 even when
109
+ // npm wrote the new version to disk. Trust the on-disk state over the exit code.
110
+ const onDisk = getInstalledVersion(SELF_PACKAGE);
111
+ if (onDisk === latestVersion) {
112
+ console.warn(`[roundhouse] self-update install reported failure but v${onDisk} is on disk — treating as success:`, msg);
113
+ return undefined;
114
+ }
81
115
  console.warn("[roundhouse] self-update failed:", msg);
82
116
  return `Self-update failed: ${msg}`;
83
117
  }
@@ -106,7 +140,7 @@ export async function performUpdate(progress: UpdateProgress): Promise<UpdateRes
106
140
 
107
141
  let latestVersion: string;
108
142
  try {
109
- latestVersion = execSync("npm view @inceptionstack/roundhouse version 2>/dev/null", {
143
+ latestVersion = execSync(`npm view ${SELF_PACKAGE} version 2>/dev/null`, {
110
144
  timeout: 30_000,
111
145
  encoding: "utf8",
112
146
  }).trim();
@@ -439,6 +439,26 @@ export class Gateway {
439
439
 
440
440
  stopTyping = startTypingLoop(thread);
441
441
 
442
+ // Pre-turn recovery: if a prior turn failed to compact (state has
443
+ // pendingCompact === "emergency"), the live session is almost certainly
444
+ // still over the model's context limit, so calling agent.prompt() now
445
+ // will throw with "prompt is too long" before our post-turn pressure
446
+ // handler ever runs — perpetuating the loop. Run the pressure handler
447
+ // BEFORE the agent call to recover. Best-effort: if it fails, fall
448
+ // through to the normal turn (which will then post the error and let
449
+ // the user see something is wrong).
450
+ if (memoryPrepared?.pendingCompact === "emergency") {
451
+ console.log(`[roundhouse] pre-turn recovery: pendingCompact=emergency, compacting before agent.prompt for thread=${agentThreadId}`);
452
+ try {
453
+ await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, "emergency");
454
+ // Clear the pending flag in our prepared snapshot so the post-turn
455
+ // pressure logic doesn't double-up on a redundant emergency pass.
456
+ memoryPrepared.pendingCompact = undefined;
457
+ } catch (err) {
458
+ console.error(`[roundhouse] pre-turn emergency compact failed:`, (err as Error).message);
459
+ }
460
+ }
461
+
442
462
  let deferredSoftFlush: { thread: any; agentThreadId: string; agent: AgentAdapter; memoryRoot: string } | undefined;
443
463
  try {
444
464
  let turnUsedTools = false;
@@ -239,7 +239,15 @@ export async function flushMemoryThenCompact(
239
239
  // NOT require the live session to fit under the limit — so skipping flush
240
240
  // lets us recover. Facts-to-MEMORY.md (the whole point of flush) is a
241
241
  // best-effort nicety that the next soft/hard flush can catch up on.
242
- const skipFlush = effectiveLevel === "emergency";
242
+ //
243
+ // We also skip flush when state already has pendingCompact === "emergency":
244
+ // a prior turn detected emergency pressure and could not complete (e.g. the
245
+ // current call was triggered by /compact while stuck). Even at "hard" or
246
+ // "manual" level, attempting the flush in that condition will hit the same
247
+ // 200k rejection. Deferring flush to a later (successful) turn is the safe
248
+ // recovery path.
249
+ const stuckInEmergency = (await loadThreadMemoryState(threadId)).pendingCompact === "emergency";
250
+ const skipFlush = effectiveLevel === "emergency" || stuckInEmergency;
243
251
 
244
252
  try {
245
253
  let flushMs = 0;
@@ -255,10 +263,12 @@ export async function flushMemoryThenCompact(
255
263
  }
256
264
 
257
265
  // Step 2: compact (use flush model if compactWithModel is available)
258
- const flushNote = skipFlush ? "flush skipped (emergency)" : `flush took ${flushMs}ms`;
266
+ const flushNote = skipFlush
267
+ ? (effectiveLevel === "emergency" ? "flush skipped (emergency)" : "flush skipped (recovery from prior emergency)")
268
+ : `flush took ${flushMs}ms`;
259
269
  console.log(`[memory] compacting ${threadId} (${flushNote})`);
260
270
  const progressNote = skipFlush
261
- ? "✂️ Compacting context... (emergency — skipping flush)"
271
+ ? `✂️ Compacting context... (${effectiveLevel === "emergency" ? "emergency " : "recovery — "}skipping flush)`
262
272
  : `✂️ Compacting context... (flush took ${(flushMs / 1000).toFixed(1)}s)`;
263
273
  await onProgress?.(progressNote);
264
274
  const t1 = Date.now();