@inetafrica/open-claudia 3.0.44 → 3.1.1
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 +46 -0
- package/README.md +12 -7
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/actions.js +22 -0
- package/core/auth-flow.js +68 -0
- package/core/boot-health.js +84 -0
- package/core/doctor-memory.js +213 -0
- package/core/doctor.js +5 -0
- package/core/dream.js +11 -9
- package/core/entities.js +47 -3
- package/core/handlers.js +160 -2
- package/core/keyring.js +24 -4
- package/core/memory-mutation-queue.js +42 -11
- package/core/pack-review.js +60 -4
- package/core/packs.js +47 -3
- package/core/recall/discoverer.js +22 -6
- package/core/recall/graph.js +84 -52
- package/core/recall/tuning.js +1 -2
- package/core/router.js +18 -0
- package/core/runner.js +21 -0
- package/core/safe-mode.js +227 -0
- package/core/state.js +2 -2
- package/core/system-prompt.js +43 -3
- package/core/tool-graph.js +99 -71
- package/core/tools.js +3 -2
- package/core/turn-observer.js +23 -1
- package/package.json +3 -3
- package/test-extractive-injection.js +21 -0
- package/test-memory-mutation-queue.js +33 -3
- package/test-provider-compaction.js +5 -0
- package/test-provider-core-prompt.js +3 -0
- package/test-provider-language.js +59 -1
- package/test-provider-recall.js +33 -0
- package/test-recall-discoverer.js +8 -0
- package/test-recall-graph.js +66 -11
- package/test-tool-graph.js +67 -16
- package/test-tool-manifest.js +32 -0
- package/test-tools.js +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.1.1 — /logout and switch-account: re-auth is finally reachable from chat
|
|
4
|
+
|
|
5
|
+
Reported live: with Claude or Codex already connected, there was **no way to sign into a different account** — `/login` showed "connected" and tapping a model just switched models, because the auth-aware picker (correctly, since v3.0.36) only starts a login flow when a provider is *not* authenticated. And `/clear_oauth_token` alone didn't help: it clears the bot-held token but the CLI credential store keeps `authStatus` reading "authenticated", so the picker still never offered a sign-in. Re-auth needs a *full* sign-out first — this release adds it.
|
|
6
|
+
|
|
7
|
+
- **New `/logout [claude|codex]`** (owner-only) — a full sign-out: runs `claude auth logout` / `codex logout` against the CLI credential store **and** (for Claude) clears the bot-held OAuth token from `.env`, process env, and vault in the same step. Partial clears were exactly the trap. Reports each step honestly (including "CLI not found — store untouched"), then offers a **Sign in** button so the fresh login is one tap away. Without an argument it shows provider buttons.
|
|
8
|
+
- **`/login` picker grows a 🔁 Switch account button** under each *connected* provider. Tapping it asks first — showing who is currently signed in (email where the CLI exposes it, else the auth method: OAuth token source / ChatGPT account / API key) and warning that the bot loses that provider until the new login completes — then signs out and chains straight into the fresh login flow. A stray tap can't kill a working auth: the destructive step is behind the explicit confirm.
|
|
9
|
+
- **`/clear_oauth_token` is now documented as the narrow tool** (bot token only); `/logout claude` is the full story.
|
|
10
|
+
- **Agent-space / OpenClaw:** new auth-flow primitives `logoutClaude`/`logoutCodex`/`providerLogout`/`canLogout`/`providerAccountSummary` (core/auth-flow.js), shared executor `performProviderLogout` + `startProviderLogin` (core/handlers.js), new callback family `sw:`/`lo:`/`losw:`/`li:` (core/actions.js, owner-gated). New test `test-logout.js` in the pretest chain.
|
|
11
|
+
|
|
12
|
+
## v3.1.0 — Memory becomes required: safe-mode boot, crash-loop rescue, and /downgrade
|
|
13
|
+
|
|
14
|
+
Three waves folded into one release, graduated to a minor because boot semantics change. Wave 0: the memory substrate is now **required** — a missing `node:sqlite` boots into safe mode instead of silently degrading, a boot crash loop lands there too, and a new `/downgrade` completes the rescue-from-chat story. Wave 1: the memory-substrate revival, root-caused from a live incident — a pod ran 16 days repeating corrected branding mistakes because its entire memory substrate silently never initialized. Wave 2: the first fix batch from the 2026-07-20 blind code review (18 verified findings; deeper architectural items ship in later batches).
|
|
15
|
+
|
|
16
|
+
### Wave 0 — boot safety (why this is a minor)
|
|
17
|
+
|
|
18
|
+
- **The memory substrate is now required, not a silent fallback.** A boot without `node:sqlite` (Node < 24) no longer quietly runs with keyword-only memory — it enters **safe mode**: adapters and diagnostic slash commands stay up, but no agent turns, crons, watches, reviewer, or memory writes run. The owner gets one loud alert explaining exactly what's missing and every way out. Running degraded is still possible, but only as an explicit choice: `MEMORY_ALLOW_DEGRADED=1` in the environment, or a one-shot `/safemode continue` from chat.
|
|
19
|
+
- **A boot crash loop can no longer strand you with a dead pod.** If 3 consecutive boots die before reaching 5 minutes of stable uptime, the next boot comes up in safe mode instead of crashing a fourth time — the chat channel stays alive precisely so you can rescue it *from chat*. The alert carries the last recorded exit reason (or names it a hard kill — OOM/SIGKILL — when there is none). The counter is honest about edge cases: a clean deliberate exit (restart/upgrade) never counts as a crash, a hard kill *after* proven-stable uptime starts a fresh count, and safe mode's own stability doesn't launder the streak away — only a stable full boot, `/safemode continue`, or a deliberate version change resets it.
|
|
20
|
+
- **New `/downgrade` — roll back to what ran before.** With no argument it targets the previously-running version from the new boot version history; `/downgrade <version>` picks any published release (strict semver-validated, existence-checked against the registry before anything executes). Works from safe mode, which turns a bad release into a two-command recovery: crash loop → safe mode → `/downgrade` → back online. On source-overlay pods the change is flagged as ephemeral (a pod recreate returns to the image version — roll the image for permanence). Both `/upgrade` and `/downgrade` clear the crash counter, since a deliberate version change is an operator fix attempt.
|
|
21
|
+
- **`/safemode`** shows the active reasons, last exit, and ways out at any time (and reports "running normally" when not in safe mode). `/status` gains a ⛔ banner line while safe mode is active. Non-allowlisted messages in safe mode get a one-line banner pointing at `/safemode` instead of silence.
|
|
22
|
+
- **`package.json` now declares `engines.node >= 24`.** npm warns at install on older runtimes; the bot itself still *boots* on them — deliberately, straight into safe mode, so an old pod becomes a diagnosable chat instead of a crash loop.
|
|
23
|
+
- **Agent-space / OpenClaw (wave 0):** new module `core/safe-mode.js` (boot gate consulted at the top of `bot.js`; router gates every message/action through it); new state files under the config dir: `boot-marker.json`, `safemode-continue.json`, `version-history.json` (last 20 versions seen at boot). New commands `/downgrade`, `/safemode`; safe-mode command allowlist: help, start, version, status, doctor, requirements, restart, upgrade, downgrade, safemode, whoami, cluster. New test `test-safe-mode.js` in the pretest chain (which also now runs the previously-unwired `test-boot-health.js` and `test-lexical-floor.js`).
|
|
24
|
+
|
|
25
|
+
### Wave 1 — memory-substrate revival
|
|
26
|
+
|
|
27
|
+
- **Why a pod forgot everything for 16 days — and why that can never be silent again.** The pod base image ran Node 20, which has no `node:sqlite` — so pack FTS matching, the recall graph, and episodic memory all bailed out quietly at require-time. 542 turns produced zero recall seeds; three separate user corrections were reviewed and dropped; nothing anywhere said a word. `Dockerfile.base` now builds `FROM node:24-slim` (node:sqlite stable), and the CI build job tests on `node:24` to match — with the `sim-no-sqlite` shim tests keeping the degraded path covered.
|
|
28
|
+
- **/doctor grew a full memory-health section.** Substrate presence (hard fail with the exact fix when node:sqlite is missing), recall-graph size, the last 24h of the recall pipeline (turns, seeded, injected, walker failures — 10+ live turns with zero seeds is a hard fail: "matching is returning NOTHING"), reviewer success rate (<50% fail, <90% warn, deferred counts, last error code), mutation-lock state (free / stale / long-held / active), deferred review batches on disk, corpus size vs the lesson cap, and disk headroom.
|
|
29
|
+
- **Substrate degradation announces itself at boot.** A degraded boot sends the owner one loud alert ("⚠️ MEMORY SUBSTRATE DEGRADED … I will remember poorly until this is fixed") — deduped per capability fingerprint, re-fired every 24h while broken, cleared the moment a healthy boot is seen so a later regression alerts fresh.
|
|
30
|
+
- **Every upgrade is now followed by a doctor run on the NEW process.** All three `/upgrade` paths (AgentSpace rollout, source-overlay, npm) record a marker before restarting; the next boot consumes it and sends a full doctor report back to the channel that requested the upgrade. The pre-restart report can only ever vouch for the *old* process — this closes the gap where an upgrade lands on a broken substrate and nobody notices.
|
|
31
|
+
- **Recall now has a floor: no sqlite means keyword matching, never nothing.** `matchPacks`/`matchEntities` fall back to a pure-JS lexical scorer that mirrors the FTS scoring exactly (strong-field hit 2, body hit 1, same thresholds and limits, porter stemming approximated by substring + suffix-strip variants). Both recall engines get seeds through the same two matchers, so the floor fixes classic *and* discoverer with zero engine changes — proven equivalent by a parity test that runs the same assertions with and without the no-sqlite shim.
|
|
32
|
+
- **The memory reviewer can no longer be starved by a stale lock.** In a container the bot is PID 1 — and after a crash, the *restarted* bot is also PID 1, so the old liveness check saw a dead incarnation's mutation lock as alive forever (one pod: 87% of reviewer runs starved with `MEMORY_MUTATION_LOCK_TIMEOUT`, feeding a 43-restart loop). Locks now carry a per-process boot id: same pid + different boot id breaks immediately, ownerless locks break after a 5s grace, and a genuinely live holder is still never broken. The reviewer also gained a seatbelt: on lock timeout it retries once after 60s, and if still starved it persists the decision batch to `pending-review-mutations.jsonl` (capped, surfaced by /doctor) and records the run as "deferred" instead of losing the memory write.
|
|
33
|
+
- **LOCKED stance lines now carry rule authority.** A Stance line containing `LOCKED` (uppercase — prose "locked" stays put) is hoisted under a "BINDING CONSTRAINTS" header in every injection mode, so a frozen choice reads as a rule, not background prose. The reviewer prompt gained the matching contract: never weaken, reword, or drop a LOCKED line unless the user explicitly unlocks it, and when a reply violates one, journal the violation ("VIOLATION: …") so drift becomes visible instead of silently absorbed.
|
|
34
|
+
- **Compaction stops matching memory against its own boilerplate.** The summarize meta-turn now skips recall entirely (its prompt is instructions about summarizing — matching packs against it was noise). The seed turn — which opens a fresh session and previously lost all pack context — now matches on what the work *is*: the condensed brief plus open task titles, so the packs active before compaction re-attract immediately. Lessons stay always-on, and re-attracted packs bring their LOCKED constraints back with authority.
|
|
35
|
+
- **Agent-space / OpenClaw (wave 1):** base-image change `FROM node:20-slim` → `node:24-slim` (rebuild `:base` required — the CI base job triggers on the Dockerfile.base change), CI build image `node:20` → `node:24`. New modules `core/doctor-memory.js`, `core/boot-health.js`; boot wiring in `bot.js`; marker writes in the three `/upgrade` paths. New state files under the config dir: `substrate-alert.json`, `pending-doctor.json`, `pending-review-mutations.jsonl`; mutation-lock `owner.json` gains a `bootId`. New tests `test-boot-health.js`, `test-lexical-floor.js`; expanded `test-memory-mutation-queue.js`, `test-extractive-injection.js`, `test-provider-compaction.js`, `test-provider-recall.js`.
|
|
36
|
+
|
|
37
|
+
### Wave 2 — blind-review fix batch
|
|
38
|
+
|
|
39
|
+
- **The recall graph no longer eats its own memory.** The nightly decay pass rewrote every edge weight by reapplying the *full* age since last reinforcement — every night. That compounds: an edge on the intended 60-day half-life actually emptied in about ten nights, so long-lived Hebbian links quietly bled away and recall got worse the longer you used it. Decay is now computed **on read** (the stored weight is the value as of its `last_reinforced` stamp; every read maps through `effectiveWeight()`), reinforce/weaken settle the accrued decay before applying their delta, and the nightly rewrite path is gone entirely — the bug class can no longer exist. A one-time, idempotent migration (`recall-graph.db` PRAGMA `user_version` 0→1) treats existing weights as current and restarts the clock. The half-life is now a dream-tunable knob (`decayHalfLifeDays`, env-pinnable) read lazily, so a tuning change applies on the very next read.
|
|
40
|
+
- **Keyring and tool state can no longer be destroyed by a crash mid-write.** The operational keyring, tool `state.json`/usage books, and recall tuning were bare `writeFileSync` calls — an OOM kill or power loss mid-write left a truncated, unparseable file, and for the keyring that meant every stored credential gone. All of them now go through the crash-safe writer (temp file + fsync + atomic rename, 0600). The keyring additionally snapshots the last good version to `keyring.json.bak` before each overwrite and *reads through* it transparently: a corrupt primary logs one warning per process, serves the backup, and the next successful write repairs the primary in place.
|
|
41
|
+
- **When memory recall breaks, you now hear about it instead of the bot quietly getting dumber.** Recall has always been fail-open (a crashed builder must not block the turn) — but it was also fail-*silent*: the error vanished and every turn just ran memoryless. The failure now logs to the bot log, shows immediately as a "🧠 Recall FAILED this turn" banner when `/recall` is on, and after **3 consecutive** failed turns the owner gets one alert per streak ("running without pack/entity/tool context", with the last error) — quiet again until it recovers or breaks anew.
|
|
42
|
+
- **"ok deploy kazee now" no longer skips memory recall.** The recall pre-gate skipped short turns that *start* with a pleasantry — an ack prefix hid a real command, so deploy/restart requests wearing an "ok"/"thanks" ran without any pack context. The gate now strips leading pleasantry tokens (stacked ones too) and tiers what remains; pure ack chains ("ok thanks sounds good") still skip at any length.
|
|
43
|
+
- **A chat approval is no longer burned by a run that could never start.** `tool run --approval <id>` consumed the one-shot token *before* checking keyring credentials — a missing key meant the human's Approve was spent on nothing and they had to be asked again. The creds preflight now runs before any approval machinery (including owner escalation, which is no longer raised for unrunnable commands): the failure names the missing keys, the token survives, and the byte-identical command redeems it after `keyring set`.
|
|
44
|
+
- **Tool-sequence learning now sees piped compositions.** The graph that learns "tool A is usually followed by tool B" split command lines on `&&`, `||`, `;` and newlines — but not on a single `|` or background `&`, so `tool run a … | tool run b …` credited only the first tool. It now splits on all shell composition operators, with quoted arguments masked first so a pipe inside a string doesn't split the command.
|
|
45
|
+
- **Two source files no longer masquerade as binaries.** `core/recall/discoverer.js` and `core/state.js` contained raw NUL bytes inside string literals (composite-key separators), which made grep and most tooling treat the files as binary and skip them. The bytes are now written as `\u0000` escapes — byte-for-byte identical strings at runtime, plain text on disk.
|
|
46
|
+
- **README caught up with reality on recall engines.** It claimed **classic** was the default and **discoverer** opt-in; the shipped default has been `discoverer` (per-chat `/engine` → `RECALL_ENGINE` env → default). The engine docs, command table, and env table now say so, and the discoverer section documents its cost profile: one small utility-model call per non-trivial turn, skipped entirely by the pre-gate on trivial turns, model pinnable via `RECALL_DISCOVERER_MODEL`.
|
|
47
|
+
- **Agent-space / OpenClaw:** no new deps, no new env (all knobs pre-existed). One schema touch: the automatic, idempotent `recall-graph.db` `user_version` 0→1 restamp above; keyring writes now leave a `.bak` sibling. Changes span `core/recall/graph.js`/`tuning.js`/`discoverer.js`, `core/keyring.js`, `core/tools.js`, `core/tool-graph.js`, `core/state.js`, `core/system-prompt.js`, `core/turn-observer.js`, `core/dream.js`, `bin/tool.js`, README. Covered by new `test-turn-observer.js` (in the pretest chain) plus expanded cases in `test-recall-graph.js`, `test-recall-discoverer.js`, `test-tools.js`, `test-tool-graph.js`, `test-tool-manifest.js`, `test-provider-core-prompt.js`; full suite green. Existing pods pick it up on their next approved upgrade.
|
|
48
|
+
|
|
3
49
|
## v3.0.44 — Safety refusals explain themselves, and one refusal no longer poisons every turn after it
|
|
4
50
|
|
|
5
51
|
- **A Claude safety refusal now tells you why instead of dumping a raw API error.** When the model declines a request (`stop_reason: "refusal"`), the API attaches a category and sometimes an explanation — but all you saw was "API Error … appears to violate our Usage Policy" with a request id buried inside. The refusal is now detected structurally from the stream (`stop_details` category/explanation, with a text-parse fallback when the CLI flattens it into the error string) and surfaced as a clear ⛔ diagnostic: the reason, the request ID (for support), and your options (/new, rephrase, /model or /backend).
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Send text, voice notes, screenshots, and files from your phone. Open Claudia run
|
|
|
15
15
|
### Memory & long-term context
|
|
16
16
|
- **Context packs** — living per-topic documents (one per project, system, or recurring task) holding Stance, Procedure, State, and Journal. Packs matching your message are auto-injected into the agent's context, and a background reviewer updates them after every substantial turn — the assistant keeps its train of thought across sessions and projects
|
|
17
17
|
- **Entity memory** — short notes on the people, places, projects, orgs, and systems you mention, extracted automatically and injected when they come up again
|
|
18
|
-
- **Pluggable recall** — switch per chat with `/engine`: the
|
|
18
|
+
- **Pluggable recall** — switch per chat with `/engine`: the **discoverer** graph engine (default), which walks a typed-edge graph over your packs/entities and surfaces hits with one-line why-bullets, or the simpler **classic** keyword engine as an explicit opt-out
|
|
19
19
|
- **Dream consolidation** — a nightly pass on a stronger model that merges duplicate packs, builds umbrella/parent pack trees, tightens descriptions, dedupes entities, tends the recall graph, and reports what it tidied — with everything backed up first
|
|
20
20
|
- **Personality** — a persona file gives the assistant a consistent voice on top of your soul file, and the dream pass evolves it gently as you work together
|
|
21
21
|
- **Transcript search** — redacted project transcripts indexed in SQLite FTS5; `open-claudia transcript-search` gives the agent ~50ms ranked recall over months of history
|
|
@@ -54,7 +54,7 @@ Send text, voice notes, screenshots, and files from your phone. Open Claudia run
|
|
|
54
54
|
|
|
55
55
|
## Prerequisites
|
|
56
56
|
|
|
57
|
-
- [Node.js](https://nodejs.org/)
|
|
57
|
+
- [Node.js](https://nodejs.org/) **24+** (required — its built-in SQLite powers pack/entity/transcript search and the recall graph). On older Node the bot boots into **safe mode** (diagnostic commands only) instead of running with degraded memory; set `MEMORY_ALLOW_DEGRADED=1` or send `/safemode continue` to explicitly run with keyword-only memory.
|
|
58
58
|
- A Telegram bot token (from [@BotFather](https://t.me/BotFather)) and/or a Kazee Chat bot
|
|
59
59
|
- At least one authenticated coding-agent provider on the host machine for model turns (setup, doctor, status, and web configuration also work with none installed)
|
|
60
60
|
- (Optional) [whisper.cpp](https://github.com/ggerganov/whisper.cpp) + ffmpeg for voice notes
|
|
@@ -179,7 +179,7 @@ When you select a project, the last conversation is automatically resumed. Tap "
|
|
|
179
179
|
|---------|-------------|
|
|
180
180
|
| `/learn [<hint>]` | Capture the last piece of work into the matching context pack |
|
|
181
181
|
| `/skills [show\|remove <name>]` | List, show, or remove legacy learned skills |
|
|
182
|
-
| `/engine [classic\|discoverer]` | Switch the per-chat memory recall engine (default `
|
|
182
|
+
| `/engine [classic\|discoverer]` | Switch the per-chat memory recall engine (default `discoverer`) |
|
|
183
183
|
| `/recall [on\|off]` | Toggle a per-turn "🧠 Recall this turn" debug line showing which packs/entities surfaced (and why, on discoverer) |
|
|
184
184
|
| `/soul` | View/edit assistant identity and personality |
|
|
185
185
|
| `/dreamsummary [on\|off]` | Toggle the post-dream memory summary in chat |
|
|
@@ -208,10 +208,11 @@ When you select a project, the last conversation is automatically resumed. Tap "
|
|
|
208
208
|
| Command | Description |
|
|
209
209
|
|---------|-------------|
|
|
210
210
|
| `/auth_status` | Reports redacted Claude auth status and whether an OAuth token is configured |
|
|
211
|
-
| `/login` | Starts a Claude login flow, sends the URL/code, accepts paste-back codes (`/auth_code <code>`, `/cancel_auth`) |
|
|
211
|
+
| `/login` | Starts a Claude login flow, sends the URL/code, accepts paste-back codes (`/auth_code <code>`, `/cancel_auth`). Connected providers show a **🔁 Switch account** button that signs out and starts a fresh login |
|
|
212
|
+
| `/logout [claude\|codex]` | Full sign-out: clears the CLI credential store **and** (for Claude) the bot-held OAuth token, then offers a sign-in button. Without an argument, shows provider buttons |
|
|
212
213
|
| `/setup_token` | Runs `claude setup-token`; the resulting OAuth token is stored without echoing it |
|
|
213
214
|
| `/use_oauth_token [<token>]` | Stores `CLAUDE_CODE_OAUTH_TOKEN` for non-interactive runs; the message is deleted when possible. Without an argument, send the token as the next message |
|
|
214
|
-
| `/clear_oauth_token` | Removes the stored OAuth token |
|
|
215
|
+
| `/clear_oauth_token` | Removes the stored OAuth token only (`/logout claude` for a full sign-out) |
|
|
215
216
|
|
|
216
217
|
Tokens are redacted from chat output and logs.
|
|
217
218
|
|
|
@@ -230,6 +231,8 @@ Tokens are redacted from chat output and logs.
|
|
|
230
231
|
|---------|-------------|
|
|
231
232
|
| `/version` | Show current running version |
|
|
232
233
|
| `/upgrade` | Upgrade to latest version and restart (includes a post-upgrade doctor summary) |
|
|
234
|
+
| `/downgrade [version]` | Roll back to the previously-running version (or a specific one) |
|
|
235
|
+
| `/safemode` | Show safe-mode status and ways out; `/safemode continue` boots normally despite a missing memory substrate |
|
|
233
236
|
| `/restart` | Restart the bot |
|
|
234
237
|
| `/dashboard` | Get a one-time login link to the web dashboard |
|
|
235
238
|
| `/stop` | Cancel a running task |
|
|
@@ -256,7 +259,7 @@ Open Claudia layers three memory systems on top of provider-native sessions:
|
|
|
256
259
|
|
|
257
260
|
**Entity memory** (`~/.open-claudia/entities/<slug>.md`) works the same way for the people, places, projects, orgs, and systems you mention — who they are, current truth, and a dated observation log. Mentioning a name injects its note.
|
|
258
261
|
|
|
259
|
-
**Recall engines** — how packs and entities get matched and surfaced is pluggable per chat via `/engine` (or the `RECALL_ENGINE` env default). **
|
|
262
|
+
**Recall engines** — how packs and entities get matched and surfaced is pluggable per chat via `/engine` (or the `RECALL_ENGINE` env default). **discoverer** (the default) runs a typed-edge graph over the corpus (`parent`/`governed-by`/`related` edges with weights in `recall-graph.db`): a pre-gate that skips recall on trivial turns → FTS seeding → spreading activation across the graph (1–2 hops — auto-pulls cross-cutting concerns the query never named) → a walker that reads each candidate and returns the genuinely-relevant set with one-line why-bullets (fail-open to keyword seeds, so it never recalls worse than classic). The walker is one small utility-model call per non-trivial turn (typically fractions of a cent; pin the model with `RECALL_DISCOVERER_MODEL`, and trivial/short turns skip it entirely via the pre-gate). Edges form structurally from pack `parent` frontmatter and `[[links]]`, and strengthen via Hebbian co-use when the agent opens packs together (📖); weights decay over time. **classic** is the explicit opt-out: keyword FTS plus a relevance judge with headline injection, no graph and no walker. Inspect with `open-claudia recall-stats` and `open-claudia recall graph [--sync]`, or flip on `/recall` to watch — per turn — which packs/entities surfaced and why, right in the chat. Switch any time with `/engine classic`.
|
|
260
263
|
|
|
261
264
|
**Dream consolidation** — while the per-turn reviewer takes quick notes, *dream* is the slow overnight pass (default 4am, through the configured provider's high model tier): it merges packs that drifted into the same topic, builds parent/sub pack trees with umbrella summaries, tightens descriptions and tags so the router matches with less noise, dedupes entities, cross-links notes, and tends the recall graph (structural sync, weight decay, orphan prune). The pass is *evidence-grounded*: per-turn recall telemetry (what surfaced, what got kept, what the agent actually opened) feeds the prompt, so archive/merge calls rest on usage numbers rather than vibes. Most nights run as cheap *deltas* — full pack bodies only for what changed since the last dream plus graph neighbours and pre-computed merge candidates; a full-corpus sweep runs every `DREAM_FULL_SWEEP_DAYS` (30). Deterministic phases run even if the model call fails: journal backfill dedupe, weakening of graph nodes that keep arriving but never get kept, co-rescue edge reinforcement, episodic index tending, and a memory-health report (rescue rate, noise, latency, spend) in the morning chat summary. Dream may also tune one bounded recall knob per night (`recall-tuning.json`) — every change is checked against the next window's health and auto-rolled-back if rescue drops or noise rises; env pins always win. Anything merged away is backed up under `~/.open-claudia/backup/dream-<stamp>/` first, and each report and morning summary records the provider/model used. Configure with `DREAM_CRON`, `DREAM_TIER`, `DREAM_PROVIDER`, or a provider-specific `DREAM_MODEL_CLAUDE` / `DREAM_MODEL_CODEX`; disable with `DREAM=off`.
|
|
262
265
|
|
|
@@ -451,7 +454,7 @@ All stored in `~/.open-claudia/`:
|
|
|
451
454
|
| `USAGE_ALERT_RATE_MULTIPLIER` | No | Alert when the latest context-token rate exceeds the recent baseline by this multiple (default `1.75`, `off` disables) |
|
|
452
455
|
| `USAGE_ALERT_BASELINE_TURNS` / `USAGE_ALERT_MIN_BASELINE_TURNS` / `USAGE_ALERT_COOLDOWN_MS` | No | Tune token-rate baseline size, minimum sample size, and alert cooldown |
|
|
453
456
|
| `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
|
|
454
|
-
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `
|
|
457
|
+
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
|
|
455
458
|
| `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
|
|
456
459
|
| `RECALL_WALKER_MAX_CANDIDATES` | No | Cap on candidates sent to the discoverer's walker judge per turn (default `14`; dream-tunable 8–20, env pins it) |
|
|
457
460
|
| `RECALL_EPISODES` / `RECALL_EPISODE_LIMIT` | No | `off` disables episodic transcript candidates in recall; limit caps transcript hits per turn (default `3`; dream-tunable 1–5) |
|
|
@@ -513,6 +516,8 @@ The bot checks npm for new versions every 5 minutes. When an update is available
|
|
|
513
516
|
|
|
514
517
|
For direct npm installs, `/upgrade` updates Open Claudia itself and does not install optional provider CLIs such as Codex. Container deployments should roll out a new Docker image when bundled CLI tools change.
|
|
515
518
|
|
|
519
|
+
If a release goes bad, `/downgrade` rolls back to the previously-running version (boot history remembers what ran before), and `/downgrade <version>` targets any published release. If the bot ever enters a boot crash loop (3 consecutive boots dying before stable uptime), it comes up in **safe mode** — adapters and diagnostic commands stay alive so you can `/doctor`, `/upgrade`, `/downgrade`, or `/safemode continue` from chat instead of being stranded with a dead process.
|
|
520
|
+
|
|
516
521
|
## Cron Jobs
|
|
517
522
|
|
|
518
523
|
Schedule recurring tasks:
|
package/bin/tool.js
CHANGED
|
@@ -375,6 +375,21 @@ async function run(args) {
|
|
|
375
375
|
const commandLine = approvals.formatApprovedCommand(["open-claudia", "tool", "run", t.name, ...toolArgs]);
|
|
376
376
|
let approvedVia = "";
|
|
377
377
|
|
|
378
|
+
// B10: creds preflight BEFORE any approval machinery. A missing keyring
|
|
379
|
+
// key used to be discovered only after the one-shot --approval token was
|
|
380
|
+
// consumed (or an owner approval raised), burning a human decision on a
|
|
381
|
+
// run that could never start. Failing here leaves every token valid.
|
|
382
|
+
{
|
|
383
|
+
const missing = tools.missingRequires(t);
|
|
384
|
+
if (missing.length) {
|
|
385
|
+
console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
|
|
386
|
+
if (approvalId) {
|
|
387
|
+
console.error(`Approval ${approvalId} was NOT consumed — after setting the keys, re-run the exact same command with the same --approval token.`);
|
|
388
|
+
}
|
|
389
|
+
process.exitCode = 1; return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
378
393
|
// ── Phase 3.5: per-speaker external gate ──────────────────────────────
|
|
379
394
|
// When this run is FOR an external (non-owner) speaker, a write/destructive
|
|
380
395
|
// tool cannot rely on the agent's own --yes flag — the external could have
|
|
@@ -486,11 +501,6 @@ async function run(args) {
|
|
|
486
501
|
console.error(`✅ Approval ${reqId} redeemed (single use) — running.`);
|
|
487
502
|
}
|
|
488
503
|
|
|
489
|
-
const missing = tools.missingRequires(t);
|
|
490
|
-
if (missing.length) {
|
|
491
|
-
console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
|
|
492
|
-
process.exitCode = 1; return;
|
|
493
|
-
}
|
|
494
504
|
// Fingerprint check: warn (never block) when the source changed since the
|
|
495
505
|
// last verified (exit-0) run — the next green run re-verifies it.
|
|
496
506
|
try {
|
package/bot.js
CHANGED
|
@@ -69,6 +69,18 @@ require("./core/handlers"); // side-effect: register slash commands
|
|
|
69
69
|
|
|
70
70
|
const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
|
|
71
71
|
|
|
72
|
+
// Boot gate: decide full / degraded / safe before anything heavy initializes.
|
|
73
|
+
// Consumes the exit-reason record once (reused for the Back-online greeting).
|
|
74
|
+
// Safe mode (missing substrate, or a boot crash loop) keeps adapters + slash
|
|
75
|
+
// commands alive so the operator can diagnose and /upgrade, /downgrade or
|
|
76
|
+
// /safemode continue from chat instead of facing a dead bot.
|
|
77
|
+
const safeBoot = require("./core/safe-mode");
|
|
78
|
+
const lastExitInfo = require("./core/restart-reason").consumeExitReason();
|
|
79
|
+
const bootMode = safeBoot.beginBoot({ exitInfo: lastExitInfo, currentVersion: CURRENT_VERSION });
|
|
80
|
+
if (bootMode.mode !== "full") {
|
|
81
|
+
console.error(`Boot mode: ${bootMode.mode}${bootMode.reasons.length ? ` (${bootMode.reasons.join(", ")})` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
// Register existing operational-keyring values so the redactor scrubs them
|
|
73
85
|
// from any output we ship to chat. (New ones are registered on /keyring set.)
|
|
74
86
|
try {
|
|
@@ -226,7 +238,33 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
226
238
|
|
|
227
239
|
// ── Boot ────────────────────────────────────────────────────────────
|
|
228
240
|
|
|
241
|
+
// Minimal boot: adapters + slash commands only. No scheduler/crons, no dream,
|
|
242
|
+
// no connected-apps, no loopback, no web UI, no onboarding — nothing that can
|
|
243
|
+
// run an agent turn or write memory. The alert tells the owner exactly why and
|
|
244
|
+
// which commands still work.
|
|
245
|
+
async function safeModeBoot() {
|
|
246
|
+
for (const adapter of adapters) {
|
|
247
|
+
try {
|
|
248
|
+
await adapter.start();
|
|
249
|
+
await adapter.registerCommands(publicCommands());
|
|
250
|
+
console.log(`Adapter ready (safe mode): ${adapter.id} (${adapter.type})`);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error(`Adapter ${adapter.id} failed to start in safe mode:`, e.message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const alert = safeBoot.formatSafeModeAlert({ version: CURRENT_VERSION });
|
|
256
|
+
console.error(alert);
|
|
257
|
+
const tg = adapters.find((a) => a.type === "telegram");
|
|
258
|
+
try { if (tg && CHAT_ID) await tg.send(CHAT_ID, alert); } catch (e) {}
|
|
259
|
+
console.log(`Open Claudia v${CURRENT_VERSION} in SAFE MODE on: ${adapters.map((a) => a.id).join(", ") || "no channels"}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
229
262
|
(async () => {
|
|
263
|
+
if (bootMode.mode === "safe") {
|
|
264
|
+
await safeModeBoot();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
230
268
|
// Idempotent setup: ensure tools & config are in place (safe on every boot)
|
|
231
269
|
try {
|
|
232
270
|
const { ensureSetup, formatSetupResults } = require("./health");
|
|
@@ -311,6 +349,10 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
311
349
|
}
|
|
312
350
|
}
|
|
313
351
|
|
|
352
|
+
// This incarnation counts as healthy once it survives the stable window —
|
|
353
|
+
// resets the crash-loop streak the safe-mode gate counts at boot.
|
|
354
|
+
setTimeout(() => { try { safeBoot.markBootStable(); } catch (e) {} }, safeBoot.STABLE_UPTIME_MS).unref();
|
|
355
|
+
|
|
314
356
|
console.log(`Open Claudia v${CURRENT_VERSION} running on: ${adapters.map((a) => a.id).join(", ")}`);
|
|
315
357
|
console.log(`Runtime mode: ${runtimeMode} (single modular runtime)`);
|
|
316
358
|
console.log(`Workspace: ${require("./core/config").WORKSPACE}`);
|
|
@@ -324,9 +366,9 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
324
366
|
// chat_id — boot onboarding below opens the DM when outreach is needed.
|
|
325
367
|
// Skipped entirely pre-onboarding: a fresh pod's first words should be
|
|
326
368
|
// the onboarding greeting, not "Back online".
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
const exitInfo =
|
|
369
|
+
// Exit reason was consumed once at the top of boot (the safe-mode gate needs
|
|
370
|
+
// it); reuse it here. No record ⇒ an external hard restart nothing could log.
|
|
371
|
+
const exitInfo = lastExitInfo;
|
|
330
372
|
const reasonLine = exitInfo
|
|
331
373
|
? `\n↳ Reboot reason: ${exitInfo.reason}`
|
|
332
374
|
: "\n↳ Reboot reason: external / hard restart — no code-side reason recorded (e.g. SIGKILL, OOM, host or k8s force-restart).";
|
|
@@ -338,6 +380,39 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
338
380
|
} catch (e) {}
|
|
339
381
|
}
|
|
340
382
|
|
|
383
|
+
// Post-upgrade doctor gate + substrate degradation alert. The /upgrade
|
|
384
|
+
// pre-restart report can only see the OLD process; the marker makes the NEW
|
|
385
|
+
// one verify itself and report. Independently, a missing node:sqlite must
|
|
386
|
+
// announce itself loudly instead of silently running memory-blind.
|
|
387
|
+
try {
|
|
388
|
+
const bootHealth = require("./core/boot-health");
|
|
389
|
+
const marker = bootHealth.consumePendingDoctor();
|
|
390
|
+
let doctorReportSent = false;
|
|
391
|
+
if (marker) {
|
|
392
|
+
const { runDoctorChecks, formatDoctorReport } = require("./core/doctor");
|
|
393
|
+
const report = formatDoctorReport(runDoctorChecks());
|
|
394
|
+
const header = `Post-upgrade health check (${marker.reason || "upgrade"}), now running v${CURRENT_VERSION}:`;
|
|
395
|
+
const target = adapters.find((a) => a.type === (marker.adapterType || "telegram")) || adapters.find((a) => a.type === "telegram");
|
|
396
|
+
const channel = (target && target.type === marker.adapterType && marker.channel) || CHAT_ID;
|
|
397
|
+
try {
|
|
398
|
+
if (target && channel) {
|
|
399
|
+
const msg = `${header}\n\n${report}`;
|
|
400
|
+
await target.send(channel, msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
401
|
+
doctorReportSent = true;
|
|
402
|
+
}
|
|
403
|
+
} catch (e) {}
|
|
404
|
+
if (!doctorReportSent) console.log(`${header}\n${report}`);
|
|
405
|
+
}
|
|
406
|
+
const alert = bootHealth.checkSubstrateAlert({ suppress: doctorReportSent });
|
|
407
|
+
if (alert) {
|
|
408
|
+
console.error(alert);
|
|
409
|
+
const tg = adapters.find((a) => a.type === "telegram");
|
|
410
|
+
try { if (tg && CHAT_ID && isOnboarded()) await tg.send(CHAT_ID, alert); } catch (e) {}
|
|
411
|
+
}
|
|
412
|
+
} catch (e) {
|
|
413
|
+
console.error("boot health check failed:", e.message);
|
|
414
|
+
}
|
|
415
|
+
|
|
341
416
|
// Proactive first-boot outreach: greet an env-provisioned owner, guide them
|
|
342
417
|
// through /login, then run the LLM onboarding interview once authed.
|
|
343
418
|
try { await require("./core/boot-onboarding").initBootOnboarding(adapters); }
|
package/core/actions.js
CHANGED
|
@@ -22,6 +22,7 @@ const { finishOnboarding } = require("./onboarding");
|
|
|
22
22
|
const {
|
|
23
23
|
startSession, switchProviderState, startNewConversation,
|
|
24
24
|
endCurrentSession, activeContinueOptions, applyProviderSetting,
|
|
25
|
+
performProviderLogout, startProviderLogin,
|
|
25
26
|
} = require("./handlers");
|
|
26
27
|
const providerRegistry = require("./providers");
|
|
27
28
|
const jobs = require("./jobs");
|
|
@@ -29,6 +30,7 @@ const scheduler = require("./scheduler");
|
|
|
29
30
|
const { sideChatCoordinator } = require("./side-chat");
|
|
30
31
|
const {
|
|
31
32
|
claudeProviderAuthStatus, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
|
|
33
|
+
canLogout, providerAccountSummary,
|
|
32
34
|
} = require("./auth-flow");
|
|
33
35
|
|
|
34
36
|
const REMOVED_PROVIDER_CALLBACK_MESSAGE = "This button is from an older Open Claudia release. Cursor Agent support has been removed. Open /backend to choose Claude Code or OpenAI Codex.";
|
|
@@ -350,6 +352,26 @@ async function handleAction(envelope) {
|
|
|
350
352
|
await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
|
|
351
353
|
return;
|
|
352
354
|
}
|
|
355
|
+
// Switch-account / sign-out / sign-in buttons (from the /login picker and
|
|
356
|
+
// /logout). sw: asks first — a stray tap must not kill a working auth.
|
|
357
|
+
if (d.startsWith("sw:") || d.startsWith("lo:") || d.startsWith("losw:") || d.startsWith("li:")) {
|
|
358
|
+
if (!isChatOwner(envelope.channelId)) return send("Owner only — provider auth is shared across users.");
|
|
359
|
+
const providerId = d.slice(d.indexOf(":") + 1);
|
|
360
|
+
if (!canLogout(providerId)) return send(`No sign-out flow for "${providerId}". Open /login again.`);
|
|
361
|
+
const label = providerId === "claude" ? "Claude" : "Codex";
|
|
362
|
+
if (d.startsWith("sw:")) {
|
|
363
|
+
const account = providerAccountSummary(providerId);
|
|
364
|
+
return send(
|
|
365
|
+
`${label} is signed in as: ${account}\n\nSign out and start a fresh sign-in? The bot loses ${label} until the new login completes.`,
|
|
366
|
+
{ keyboard: { inline_keyboard: [[
|
|
367
|
+
{ text: `Yes — switch ${label} account`, callback_data: `losw:${providerId}` },
|
|
368
|
+
{ text: "Cancel", callback_data: "noop" },
|
|
369
|
+
]] } },
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (d.startsWith("li:")) return startProviderLogin(providerId);
|
|
373
|
+
return performProviderLogout(providerId, { thenLogin: d.startsWith("losw:") });
|
|
374
|
+
}
|
|
353
375
|
if (d.startsWith("m:")) {
|
|
354
376
|
state.settings.model = d.slice(2) === "default" ? null : d.slice(2);
|
|
355
377
|
await send(`Model: ${state.settings.model || "default"}`);
|
package/core/auth-flow.js
CHANGED
|
@@ -494,6 +494,69 @@ async function runCodexDeviceLogin() {
|
|
|
494
494
|
});
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
// ── Logout / switch account ────────────────────────────────────────
|
|
498
|
+
// Full sign-out clears BOTH credential sources. A partial clear is the
|
|
499
|
+
// re-auth trap: authStatus stays "authenticated" via the other source, so
|
|
500
|
+
// the /login picker never offers a fresh sign-in (the v3.0.36 side effect).
|
|
501
|
+
|
|
502
|
+
function logoutClaude(deps = {}) {
|
|
503
|
+
const steps = [];
|
|
504
|
+
const tokenInfo = (deps.getClaudeOAuthToken || getClaudeOAuthToken)();
|
|
505
|
+
if (tokenInfo.value) {
|
|
506
|
+
(deps.clearClaudeOAuthToken || clearClaudeOAuthToken)();
|
|
507
|
+
steps.push(`Bot OAuth token cleared (${tokenInfo.source})`);
|
|
508
|
+
}
|
|
509
|
+
const binary = deps.binary !== undefined ? deps.binary : claudeExecutable();
|
|
510
|
+
if (!binary) {
|
|
511
|
+
steps.push("Claude CLI not found — CLI credential store untouched");
|
|
512
|
+
return { ok: steps.length > 1 || !!tokenInfo.value, steps };
|
|
513
|
+
}
|
|
514
|
+
const result = (deps.runCommand || runCommand)(binary, ["auth", "logout"], { env: claudeSubprocessEnv(), timeout: 15000 });
|
|
515
|
+
steps.push(result.ok ? "Claude CLI store signed out" : `Claude CLI logout: ${summarizeAuthOutput(result.output, false)}`);
|
|
516
|
+
return { ok: result.ok || !!tokenInfo.value, steps };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function logoutCodex(deps = {}) {
|
|
520
|
+
const binary = deps.binary !== undefined ? deps.binary : codexExecutable();
|
|
521
|
+
if (!binary) return { ok: false, steps: ["Codex CLI not found — nothing to sign out"] };
|
|
522
|
+
const result = (deps.runCommand || runCommand)(binary, ["logout"], { env: codexSubprocessEnv(), timeout: 15000 });
|
|
523
|
+
return { ok: result.ok, steps: [result.ok ? "Codex CLI store signed out" : `Codex logout: ${summarizeAuthOutput(result.output, false)}`] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const PROVIDER_LOGOUTS = { claude: logoutClaude, codex: logoutCodex };
|
|
527
|
+
|
|
528
|
+
function canLogout(providerId) {
|
|
529
|
+
return Object.prototype.hasOwnProperty.call(PROVIDER_LOGOUTS, providerId);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function providerLogout(providerId, deps = {}) {
|
|
533
|
+
const fn = PROVIDER_LOGOUTS[providerId];
|
|
534
|
+
if (!fn) return { ok: false, steps: [`No sign-out flow for provider "${providerId}"`] };
|
|
535
|
+
return fn(deps);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Short "who is signed in" line for confirm prompts — best effort, only
|
|
539
|
+
// what the CLI exposes.
|
|
540
|
+
function providerAccountSummary(providerId) {
|
|
541
|
+
if (providerId === "claude") {
|
|
542
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
543
|
+
if (tokenInfo.value) return `OAuth token (${tokenInfo.source})`;
|
|
544
|
+
const output = runClaudeAuthStatusDiagnostic();
|
|
545
|
+
const email = (String(output).match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
546
|
+
return email || "CLI credential store";
|
|
547
|
+
}
|
|
548
|
+
if (providerId === "codex") {
|
|
549
|
+
const status = runCodexLoginStatus();
|
|
550
|
+
if (!status.ok) return "not signed in";
|
|
551
|
+
const email = (String(status.output).match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
552
|
+
if (email) return email;
|
|
553
|
+
if (/chatgpt/i.test(status.output)) return "ChatGPT account";
|
|
554
|
+
if (/api key/i.test(status.output)) return "API key";
|
|
555
|
+
return "CLI credential store";
|
|
556
|
+
}
|
|
557
|
+
return "unknown";
|
|
558
|
+
}
|
|
559
|
+
|
|
497
560
|
module.exports = {
|
|
498
561
|
CLAUDE_OAUTH_TOKEN_KEY, CLAUDE_OAUTH_VAULT_KEY,
|
|
499
562
|
getClaudeOAuthToken,
|
|
@@ -527,4 +590,9 @@ module.exports = {
|
|
|
527
590
|
sendCodexAuthStatusSummary,
|
|
528
591
|
saveCodexApiKeyWithCli,
|
|
529
592
|
runCodexDeviceLogin,
|
|
593
|
+
logoutClaude,
|
|
594
|
+
logoutCodex,
|
|
595
|
+
canLogout,
|
|
596
|
+
providerLogout,
|
|
597
|
+
providerAccountSummary,
|
|
530
598
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Boot-time health wiring: the loud substrate-degradation alert and the
|
|
2
|
+
// post-upgrade doctor gate. A node-20 pod once ran 16 days with its whole
|
|
3
|
+
// memory substrate silently absent — degradation must announce itself at
|
|
4
|
+
// boot, and every upgrade must be followed by a health check on the NEW
|
|
5
|
+
// process (the pre-restart report in /upgrade can only see the old one).
|
|
6
|
+
// bot.js does the adapter wiring; this module owns state and messages.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const CONFIG_DIR = require("../config-dir");
|
|
11
|
+
const { sqliteAvailable } = require("./doctor-memory");
|
|
12
|
+
|
|
13
|
+
const ALERT_STATE_FILE = path.join(CONFIG_DIR, "substrate-alert.json");
|
|
14
|
+
const PENDING_DOCTOR_FILE = path.join(CONFIG_DIR, "pending-doctor.json");
|
|
15
|
+
const ALERT_REPEAT_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function capabilityFingerprint() {
|
|
18
|
+
return `node:${process.version}|sqlite:${sqliteAvailable() ? "ok" : "missing"}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Owner-facing degradation alert, deduped: fires once per capability
|
|
22
|
+
// fingerprint, re-fires if the fingerprint changes or after 24h. A healthy
|
|
23
|
+
// substrate clears the state so a later regression alerts again. `suppress`
|
|
24
|
+
// marks the state without returning the message (used when a full doctor
|
|
25
|
+
// report was already sent this boot).
|
|
26
|
+
function checkSubstrateAlert({ suppress = false, now = Date.now() } = {}) {
|
|
27
|
+
if (sqliteAvailable()) {
|
|
28
|
+
try { fs.rmSync(ALERT_STATE_FILE, { force: true }); } catch (_) {}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const fp = capabilityFingerprint();
|
|
32
|
+
try {
|
|
33
|
+
const state = JSON.parse(fs.readFileSync(ALERT_STATE_FILE, "utf-8"));
|
|
34
|
+
if (state.fingerprint === fp && now - Date.parse(state.at || 0) < ALERT_REPEAT_MS) return null;
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
38
|
+
fs.writeFileSync(ALERT_STATE_FILE, JSON.stringify({ fingerprint: fp, at: new Date(now).toISOString() }), { mode: 0o600 });
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
if (suppress) return null;
|
|
41
|
+
return [
|
|
42
|
+
"⚠️ MEMORY SUBSTRATE DEGRADED",
|
|
43
|
+
`This runtime (Node ${process.version}) has no node:sqlite — pack matching, the recall graph and episodic memory are OFF (lexical keyword fallback only). I will remember poorly until this is fixed.`,
|
|
44
|
+
"Fix: run on Node 24+ — for pods, roll to the node:24 base image.",
|
|
45
|
+
"/doctor has the full report.",
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Called by /upgrade (all three paths) just before the restart: the next boot
|
|
50
|
+
// consumes the marker and sends a full doctor report for the NEW process.
|
|
51
|
+
function recordPendingDoctor({ reason, from, to } = {}) {
|
|
52
|
+
let adapterType = null;
|
|
53
|
+
let channel = null;
|
|
54
|
+
try {
|
|
55
|
+
const context = require("./context");
|
|
56
|
+
const adapter = context.currentAdapter();
|
|
57
|
+
adapterType = adapter ? adapter.type : null;
|
|
58
|
+
channel = context.currentChannelId() || null;
|
|
59
|
+
} catch (_) {}
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
62
|
+
fs.writeFileSync(PENDING_DOCTOR_FILE, JSON.stringify({
|
|
63
|
+
reason: String(reason || "upgrade").slice(0, 160),
|
|
64
|
+
from: from || "",
|
|
65
|
+
to: to || "",
|
|
66
|
+
adapterType,
|
|
67
|
+
channel,
|
|
68
|
+
at: new Date().toISOString(),
|
|
69
|
+
}), { mode: 0o600 });
|
|
70
|
+
return true;
|
|
71
|
+
} catch (_) { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function consumePendingDoctor() {
|
|
75
|
+
let marker = null;
|
|
76
|
+
try { marker = JSON.parse(fs.readFileSync(PENDING_DOCTOR_FILE, "utf-8")); } catch (_) { return null; }
|
|
77
|
+
try { fs.rmSync(PENDING_DOCTOR_FILE, { force: true }); } catch (_) {}
|
|
78
|
+
return marker;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
ALERT_STATE_FILE, PENDING_DOCTOR_FILE,
|
|
83
|
+
capabilityFingerprint, checkSubstrateAlert, recordPendingDoctor, consumePendingDoctor,
|
|
84
|
+
};
|