@inetafrica/open-claudia 3.0.13 → 3.0.15

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
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.15 — the owner keeps their memory on Kazee
4
+
5
+ - **Episodic recall no longer goes dark on Kazee / group chats.** Cross-conversation episodic memory (relevant snippets from the owner's past transcripts) is deliberately owner-only. The gate that decides "is this speaker the owner?" resolved the person by `findByHandle(adapter, channelId)` — but on Kazee the `channelId` is the *chat/room id*, while the owner's stored handle is their Kazee *user id*, so the lookup missed and fail-closed suppressed episodes. The owner's canonical brain, settings, and session were shared via identity unification, but this one recall layer silently went quiet — which read as "less context on Kazee." The gate now resolves the speaker by the envelope's **canonical user id** first (derived from the authenticated sender and unified across a person's linked channels), falling back to the channelId handle lookup when no canonical is in context. Fail-closed semantics for non-owners are preserved: a non-owner's canonical never matches an owner record. Regression pinned in `test-recall-relationship-gate.js` (owner recognised via canonical on a room-id channel; external still blocked).
6
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
7
+
8
+ ## v3.0.14 — the Telegram poll stops wedging, and every reboot says why
9
+
10
+ - **Root-cause fix for the restart flap.** In the cluster, DNS hands back a dead IPv6 address for `api.telegram.org` first and IPv6 egress is unreachable; under Node's default `verbatim` resolution the long-poll dials the dead address first (a latency penalty always, and a hard wedge on any AAAA-only window). `bot.js` now sets `dns.setDefaultResultOrder("ipv4first")` before any socket opens, eliminating that path for every request the process makes.
11
+ - **Half-open long-polls now fail fast instead of wedging for 10 minutes.** The Telegram `request` block gained `timeout: 40000` — with keep-alive off, `request` arms a socket-idle timeout, so a poll whose socket silently dies is killed (`ESOCKETTIMEDOUT` → `EFATAL` → hiccup → heal on a fresh socket) in ~40s rather than sitting dead until the 10-minute backstop calls `process.exit(1)` and the pod restarts. 40s sits safely above the 30s long-poll hold so healthy idle polls are never chopped; `ESOCKETTIMEDOUT` was also added explicitly to the transient-error classifier.
12
+ - **Every code-initiated reboot now reports its reason.** New `core/restart-reason.js` records *why* right before any deliberate exit (polling-wedge, uncaught exception, boot failure, graceful SIGTERM/SIGINT, `/restart`, `/upgrade`, runtime-mode switch); the "Back online" greeting reads and clears it and appends `↳ Reboot reason: …`. No record on boot ⇒ an external hard restart (SIGKILL, OOM, host or k8s force-restart) that nothing could log — the greeting says so explicitly. This turns a silent "Back online" into an actionable one.
13
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
14
+
3
15
  ## v3.0.13 — chat with the bot from its own dashboard
4
16
 
5
17
  - **The web dashboard gains a Chat tab — a real channel, not a side door.** A new in-process `WebAdapter` (`channels/web/`) registers as a first-class channel whenever the web UI is up: messages typed in the dashboard flow through the same router, identity, packs, transcripts, and approval pipeline as Telegram/Kazee/voice, and the bot's replies (including inline approval buttons, files, voice notes, live typing) stream back over `/api/chat/stream` (SSE with history replay on connect). New session-gated routes: `GET /api/chat/stream`, `POST /api/chat/send`, `POST /api/chat/action`, `GET /api/chat/media/<id>`. The channel is fixed single-owner (`web-owner`): the dashboard session already gates every call, so `access.js`/`identity.js` authorize it as the owner and — under unified identity — it resolves to the owner's canonical brain like every other owner channel.
package/bot.js CHANGED
@@ -6,6 +6,13 @@
6
6
  // - Handle process signals + crash notifications.
7
7
  // Heavy lifting lives in core/* and channels/*; this file is just the wire.
8
8
 
9
+ // Prefer IPv4 for every DNS lookup in this process. In the cluster, DNS returns
10
+ // a dead IPv6 address for api.telegram.org first and IPv6 egress is unreachable;
11
+ // with Node's default 'verbatim' order the Telegram long-poll dials the dead
12
+ // address first (250-600ms penalty, and a hard wedge on any AAAA-only window).
13
+ // ipv4first removes that path. Must run before any module opens a socket.
14
+ require("dns").setDefaultResultOrder("ipv4first");
15
+
9
16
  const path = require("path");
10
17
  const fs = require("fs");
11
18
  const { execSync } = require("child_process");
@@ -134,6 +141,7 @@ async function gracefulShutdown(signal) {
134
141
  }
135
142
  } catch (e) {}
136
143
  persist();
144
+ try { require("./core/restart-reason").recordExitReason(`Graceful shutdown on ${signal} (external stop/rollout/scale)`, { code: 0 }); } catch (e) {}
137
145
  process.exit(0);
138
146
  }
139
147
 
@@ -183,6 +191,7 @@ function notifyError(label, err) {
183
191
 
184
192
  process.on("uncaughtException", (err) => {
185
193
  notifyError("Uncaught exception", err);
194
+ try { require("./core/restart-reason").recordExitReason(`Uncaught exception: ${err?.message || err}`, { code: 1 }); } catch (e) {}
186
195
  try { persist(); } catch (e) {}
187
196
  setTimeout(() => process.exit(1), 2000);
188
197
  });
@@ -303,10 +312,16 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
303
312
  // chat_id — boot onboarding below opens the DM when outreach is needed.
304
313
  // Skipped entirely pre-onboarding: a fresh pod's first words should be
305
314
  // the onboarding greeting, not "Back online".
315
+ // Consume (and clear) the exit reason once, up front: a code-initiated exit
316
+ // recorded why; no record ⇒ an external hard restart nothing could log.
317
+ const exitInfo = require("./core/restart-reason").consumeExitReason();
318
+ const reasonLine = exitInfo
319
+ ? `\n↳ Reboot reason: ${exitInfo.reason}`
320
+ : "\n↳ Reboot reason: external / hard restart — no code-side reason recorded (e.g. SIGKILL, OOM, host or k8s force-restart).";
306
321
  for (const a of adapters) {
307
322
  try {
308
323
  if (a.type === "telegram" && CHAT_ID && isOnboarded()) {
309
- await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.`);
324
+ await a.send(CHAT_ID, `Back online and ready! Running v${CURRENT_VERSION}.${reasonLine}`);
310
325
  }
311
326
  } catch (e) {}
312
327
  }
@@ -347,5 +362,6 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
347
362
  }
348
363
  })().catch((e) => {
349
364
  console.error("Boot failure:", e);
365
+ try { require("./core/restart-reason").recordExitReason(`Boot failure: ${e?.message || e}`, { code: 1 }); } catch (e2) {}
350
366
  process.exit(1);
351
367
  });
@@ -28,7 +28,14 @@ class TelegramAdapter {
28
28
  this._agent = new https.Agent({ keepAlive: false });
29
29
  this.bot = new TelegramBot(token, {
30
30
  polling: { autoStart: false, params: { timeout: 30 } },
31
- request: { agent: this._agent },
31
+ // Socket-idle timeout on every request (getUpdates included). With
32
+ // keepAlive off, `request` arms req.setTimeout, so a half-open long-poll
33
+ // that stops sending bytes is killed here (ESOCKETTIMEDOUT → EFATAL →
34
+ // _onPollingHiccup → heal on a fresh socket) in ~40s instead of silently
35
+ // wedging until the 10-min backstop exits the process. MUST exceed the
36
+ // 30s long-poll hold (params.timeout:30) or it would abort healthy idle
37
+ // polls; 40s leaves a 10s margin.
38
+ request: { agent: this._agent, timeout: 40000 },
32
39
  });
33
40
  this._listeners = { message: new Set(), action: new Set() };
34
41
  this._healTimer = null; // one active recovery in flight at a time
@@ -143,6 +150,7 @@ class TelegramAdapter {
143
150
  // wedge, and exiting busts the prompt cache (full uncached re-bill of the
144
151
  // conversation window on the next turn).
145
152
  console.error("Polling wedged >10min — exiting for a clean restart.");
153
+ try { require("../../core/restart-reason").recordExitReason("Telegram polling wedged >10min (long-poll unrecoverable) — self-exit for a clean restart", { code: 1 }); } catch (e) {}
146
154
  process.exit(1);
147
155
  }
148
156
  try {
@@ -216,7 +224,7 @@ class TelegramAdapter {
216
224
  this._on409Conflict();
217
225
  return;
218
226
  }
219
- if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
227
+ if (/ESOCKETTIMEDOUT|ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
220
228
  this._onPollingHiccup(msg); // transient — heal quietly, never exit
221
229
  return;
222
230
  }
package/core/actions.js CHANGED
@@ -557,6 +557,7 @@ async function handleAction(envelope) {
557
557
  fs.writeFileSync(modeFile, newMode);
558
558
  await send(`Switching to ${newMode} mode... restarting.`);
559
559
  await sideChatCoordinator.cancelAll();
560
+ try { require("./restart-reason").recordExitReason(`Runtime mode switch → ${newMode}`, { code: 0 }); } catch (e) {}
560
561
  setTimeout(() => process.exit(0), 500);
561
562
  return;
562
563
  }
package/core/handlers.js CHANGED
@@ -565,6 +565,7 @@ register({
565
565
  handler: async (env) => {
566
566
  if (!ownerEnv(env)) return;
567
567
  await send("Going offline for a quick restart — back in a moment.");
568
+ try { require("./restart-reason").recordExitReason("User /restart command", { code: 0 }); } catch (e) {}
568
569
  setTimeout(() => process.exit(0), 1000);
569
570
  },
570
571
  });
@@ -734,6 +735,7 @@ register({
734
735
  const tailNote = "Source refreshed in /app. If this release also changed the Dockerfile (apt packages, env, base image), the host still needs a docker pull + recreate for those.";
735
736
  const msg = `Installed v${newVersion}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\n${tailNote}\n\nRestarting...`;
736
737
  await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
738
+ try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
737
739
  setTimeout(() => process.exit(0), 2000);
738
740
  return;
739
741
  }
@@ -781,6 +783,7 @@ register({
781
783
  const doctorReport = formatDoctorReport(runDoctorChecks());
782
784
  const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
783
785
  await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
786
+ try { require("./restart-reason").recordExitReason(`User /upgrade → v${newPkg.version}`, { code: 0 }); } catch (e) {}
784
787
  } catch (e) {
785
788
  const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
786
789
  await send(`Upgrade failed:\n${errOutput}`);
@@ -109,12 +109,21 @@ const EXCERPT_CHARS = 600;
109
109
  // e.g. CLI/tests) may pull them. Same-conversation packs/entities are unaffected.
110
110
  function episodesAllowedForSpeaker() {
111
111
  try {
112
- const { currentAdapter, currentChannelId } = require("../context");
112
+ const { currentAdapter, currentChannelId, currentCanonicalUserId } = require("../context");
113
113
  const adapter = currentAdapter();
114
114
  const channelId = currentChannelId();
115
115
  if (!adapter || !channelId) return true; // no speaker context (CLI/tests) → ungated
116
116
  const people = require("../people");
117
- const speaker = people.findByHandle(adapter.type, channelId);
117
+ // Resolve the speaker by canonical user id first. On transports where the
118
+ // channelId is a room/chat id rather than the sender's user id (Kazee, group
119
+ // chats), a handle lookup keyed on channelId misses the owner and fails
120
+ // closed — silently starving them of episodic recall. The canonical id is
121
+ // derived from the authenticated sender and is unified across a person's
122
+ // linked channels, so it recognises the owner on every transport. Falls back
123
+ // to the channelId handle lookup when no canonical is in context.
124
+ const canonical = currentCanonicalUserId();
125
+ const speaker = (canonical && people.findByCanonicalUserId(canonical))
126
+ || people.findByHandle(adapter.type, channelId);
118
127
  return !!(speaker && speaker.isOwner); // unknown/non-owner → gated
119
128
  } catch (e) {
120
129
  return false; // resolution error → fail closed
@@ -0,0 +1,42 @@
1
+ // Records WHY the process is about to exit so the next boot can report the
2
+ // cause in its "Back online" greeting. Only code-initiated exits get a chance
3
+ // to write the file — an external hard-kill (SIGKILL, OOM, host reboot, k8s
4
+ // force-restart) leaves no record, which is itself the signal that the restart
5
+ // was NOT code-initiated.
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const { CONFIG_DIR } = require("./config");
10
+
11
+ const FILE = path.join(CONFIG_DIR, "last-exit.json");
12
+ // A reason older than this is treated as stale (the file was left behind by a
13
+ // much earlier exit and the real cause of *this* boot is unknown/external).
14
+ const FRESH_MS = 10 * 60 * 1000;
15
+
16
+ // Call synchronously right before a code-initiated process.exit(). Best-effort:
17
+ // never throws, never blocks the exit.
18
+ function recordExitReason(reason, extra = {}) {
19
+ try {
20
+ fs.writeFileSync(FILE, JSON.stringify({
21
+ reason: String(reason || "unspecified").slice(0, 300),
22
+ code: extra.code,
23
+ at: Date.now(),
24
+ }));
25
+ } catch (e) { /* the recorder must never itself throw */ }
26
+ }
27
+
28
+ // Read + clear the recorded reason on boot. Returns null when there is no fresh
29
+ // record ⇒ the restart was external/uncontrolled (nothing got to write a file).
30
+ function consumeExitReason() {
31
+ try {
32
+ const raw = fs.readFileSync(FILE, "utf8");
33
+ try { fs.unlinkSync(FILE); } catch (e) {}
34
+ const rec = JSON.parse(raw);
35
+ if (!rec || typeof rec.at !== "number" || Date.now() - rec.at > FRESH_MS) return null;
36
+ return rec;
37
+ } catch (e) {
38
+ return null; // absent/unreadable ⇒ external restart
39
+ }
40
+ }
41
+
42
+ module.exports = { recordExitReason, consumeExitReason };
@@ -60,5 +60,28 @@ SOUL
60
60
  echo "Configuration written to $ENV_FILE"
61
61
  fi
62
62
 
63
+ # Identity sync: pod-provisioned env is authoritative for identity keys.
64
+ # The config loader prefers .env file values over process env, so a retained
65
+ # disk attached to a NEW bot would otherwise keep the old bot's identity
66
+ # (tokens, owner). Re-seed identity keys from env on every boot — only keys
67
+ # actually set in the environment; everything else in .env is untouched.
68
+ if [ -f "$ENV_FILE" ]; then
69
+ sync_env_key() {
70
+ key="$1"; value="$2"
71
+ [ -n "$value" ] || return 0
72
+ tmp="$ENV_FILE.identity-sync"
73
+ grep -v "^${key}=" "$ENV_FILE" > "$tmp" || true
74
+ printf '%s=%s\n' "$key" "$value" >> "$tmp"
75
+ mv "$tmp" "$ENV_FILE"
76
+ }
77
+ sync_env_key CHANNELS "$CHANNELS"
78
+ sync_env_key TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN"
79
+ sync_env_key TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
80
+ sync_env_key KAZEE_URL "$KAZEE_URL"
81
+ sync_env_key KAZEE_BOT_TOKEN "$KAZEE_BOT_TOKEN"
82
+ sync_env_key KAZEE_BOT_USER_ID "$KAZEE_BOT_USER_ID"
83
+ sync_env_key KAZEE_OWNER_USER_ID "$KAZEE_OWNER_USER_ID"
84
+ fi
85
+
63
86
  # Execute the main command
64
87
  exec "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.13",
3
+ "version": "3.0.15",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -32,12 +32,11 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.13 approved by Sumeet 2026-07-13 ("ok please proceed... once done
36
- // please push it out" on the agent-space P0–P3 plan): web chat channel
37
- // in-process WebAdapter, /api/chat SSE + send/action/media routes, Chat tab
38
- // in the dashboard, and a control-token-only /api/otp/mint for AgentSpace
39
- // magic-link SSO.
40
- assert.strictEqual(pkg.version, "3.0.13", "release version must remain unchanged without explicit approval");
35
+ // 3.0.15 approved by Sumeet 2026-07-14 ("Yes fix and push"): episodic-recall
36
+ // owner gate now resolves the speaker by canonical user id (not raw channelId),
37
+ // so the owner keeps cross-conversation episodic memory on Kazee / group chats
38
+ // where channelId is a room id that never matches the stored user-id handle.
39
+ assert.strictEqual(pkg.version, "3.0.15", "release version must remain unchanged without explicit approval");
41
40
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
42
41
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
43
42
  }
@@ -39,4 +39,25 @@ assert.strictEqual(inChat("222", () => episodesAllowedForSpeaker()), false, "ext
39
39
  // Unknown channel (no person record) → fail closed.
40
40
  assert.strictEqual(inChat("999", () => episodesAllowedForSpeaker()), false, "unknown speaker → episodes blocked");
41
41
 
42
+ // Kazee / group case: the channelId is a room/chat id that does NOT match the
43
+ // owner's stored handle, but the envelope carries the owner's unified canonical
44
+ // id. Resolving by canonical must recognise the owner even though a handle
45
+ // lookup on the room id misses — otherwise the owner silently loses episodic
46
+ // recall on those transports (the Kazee "less context" regression).
47
+ const withCanon = (channelId, canonicalUserId, fn) =>
48
+ runInChat({ adapter, channelId, canonicalUserId, transport: "telegram" }, fn);
49
+ const ownerCanon = people.findById(owner.id).handles[0].canonicalUserId;
50
+ assert.strictEqual(
51
+ withCanon("room-xyz", ownerCanon, () => episodesAllowedForSpeaker()),
52
+ true,
53
+ "owner via canonical on a room-id channel → episodes allowed (Kazee/group regression)",
54
+ );
55
+ // A non-owner on a room-id channel with their own canonical stays blocked.
56
+ const extCanon = people.findById(ext.id).handles[0].canonicalUserId;
57
+ assert.strictEqual(
58
+ withCanon("room-abc", extCanon, () => episodesAllowedForSpeaker()),
59
+ false,
60
+ "external via canonical → episodes still blocked",
61
+ );
62
+
42
63
  console.log("recall relationship gate OK");