@inetafrica/open-claudia 3.0.43 → 3.1.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 +44 -0
- package/README.md +9 -5
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- 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 +118 -1
- 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/providers/claude-events.js +36 -3
- package/core/providers/events.js +11 -0
- 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/run-context.js +10 -4
- package/core/runner.js +111 -2
- package/core/safe-mode.js +227 -0
- package/core/state.js +50 -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-fixtures/fake-agent-cli.js +29 -1
- package/test-fixtures/sim-no-sqlite.js +14 -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-events.js +71 -0
- package/test-provider-language.js +61 -1
- package/test-provider-recall.js +33 -0
- package/test-provider-runner.js +50 -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,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.1.0 — Memory becomes required: safe-mode boot, crash-loop rescue, and /downgrade
|
|
4
|
+
|
|
5
|
+
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).
|
|
6
|
+
|
|
7
|
+
### Wave 0 — boot safety (why this is a minor)
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **`/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.
|
|
13
|
+
- **`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.
|
|
14
|
+
- **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`).
|
|
15
|
+
|
|
16
|
+
### Wave 1 — memory-substrate revival
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
- **/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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **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`.
|
|
27
|
+
|
|
28
|
+
### Wave 2 — blind-review fix batch
|
|
29
|
+
|
|
30
|
+
- **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.
|
|
31
|
+
- **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.
|
|
32
|
+
- **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.
|
|
33
|
+
- **"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.
|
|
34
|
+
- **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`.
|
|
35
|
+
- **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.
|
|
36
|
+
- **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.
|
|
37
|
+
- **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`.
|
|
38
|
+
- **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.
|
|
39
|
+
|
|
40
|
+
## v3.0.44 — Safety refusals explain themselves, and one refusal no longer poisons every turn after it
|
|
41
|
+
|
|
42
|
+
- **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).
|
|
43
|
+
- **One refusal no longer bricks the whole conversation.** The safety classifier scores the *entire* conversation, so resuming a session whose context tripped it re-sent the flagged material and refused again — every turn, even for innocent new messages; the only escape was knowing to type /new. Now the poisoned session is **quarantined** the moment a resumed turn is refused: its id is recorded in user state (bounded to 20, survives restarts), the stale active pointer is healed, and nothing resumes it again — the natural next turn and even an explicit `/continue <id>` are forced fresh. Session ids the CLI rotates mid-run are quarantined too.
|
|
44
|
+
- **Your refused message is retried for you, once, in a fresh session.** After quarantining, the same prompt is automatically replayed in a brand-new session — you see "♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one" and then the actual answer, so you keep working without re-typing anything. Strictly one-shot: if the fresh run is also refused, the new message itself is the problem and you get the full diagnostic with no retry loop; capture/background runs never auto-retry.
|
|
45
|
+
- **Agent-space / OpenClaw:** no new deps, env, or schema change (a `quarantinedSessions` map is added to per-user state; old state files load cleanly); Docker image builds FROM the existing `:base`. Changes span `core/providers/events.js`/`claude-events.js` (detection), `core/state.js` (quarantine bookkeeping), `core/run-context.js` (resume guard), `core/runner.js` (diagnostic + one-shot retry); covered by new `test-provider-refusal-recovery.js`, a fake-agent `refusal` scenario, and runner e2e cases. Existing pods pick it up on their next approved upgrade.
|
|
46
|
+
|
|
3
47
|
## v3.0.43 — Telegram stops rebooting into a dead network: outages now pause-and-wait instead of restart-storming
|
|
4
48
|
|
|
5
49
|
- **A host-wide network blip no longer triggers a reboot storm.** v3.0.42 fixed the *in-loop* poll wedge (a poisoned reused poller) so it heals in place — but it still kept the 10-minute `process.exit(1)` backstop, which fired even when the *entire host* had lost the network (a laptop sleep/wake, or pod egress starvation where Telegram poll+send, Kazee WS and Spaces WS all drop together). Exiting into a dead network just relaunched straight back into the same outage, so you'd see "Back online and ready! Running v3.0.42" every ~14 minutes, busting the prompt cache and spamming the chat. Now the self-exit is **reachability-gated**: before it ever restarts the process it runs a fresh, connection-fresh HEAD probe to `api.telegram.org`, and only exits if that probe genuinely **succeeds** — proving the API is reachable and it's a real in-process wedge worth a clean restart, not the host being offline.
|
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 |
|
|
@@ -230,6 +230,8 @@ Tokens are redacted from chat output and logs.
|
|
|
230
230
|
|---------|-------------|
|
|
231
231
|
| `/version` | Show current running version |
|
|
232
232
|
| `/upgrade` | Upgrade to latest version and restart (includes a post-upgrade doctor summary) |
|
|
233
|
+
| `/downgrade [version]` | Roll back to the previously-running version (or a specific one) |
|
|
234
|
+
| `/safemode` | Show safe-mode status and ways out; `/safemode continue` boots normally despite a missing memory substrate |
|
|
233
235
|
| `/restart` | Restart the bot |
|
|
234
236
|
| `/dashboard` | Get a one-time login link to the web dashboard |
|
|
235
237
|
| `/stop` | Cancel a running task |
|
|
@@ -256,7 +258,7 @@ Open Claudia layers three memory systems on top of provider-native sessions:
|
|
|
256
258
|
|
|
257
259
|
**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
260
|
|
|
259
|
-
**Recall engines** — how packs and entities get matched and surfaced is pluggable per chat via `/engine` (or the `RECALL_ENGINE` env default). **
|
|
261
|
+
**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
262
|
|
|
261
263
|
**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
264
|
|
|
@@ -451,7 +453,7 @@ All stored in `~/.open-claudia/`:
|
|
|
451
453
|
| `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
454
|
| `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
455
|
| `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 `
|
|
456
|
+
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
|
|
455
457
|
| `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
|
|
456
458
|
| `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
459
|
| `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 +515,8 @@ The bot checks npm for new versions every 5 minutes. When an update is available
|
|
|
513
515
|
|
|
514
516
|
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
517
|
|
|
518
|
+
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.
|
|
519
|
+
|
|
516
520
|
## Cron Jobs
|
|
517
521
|
|
|
518
522
|
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); }
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Memory-substrate health checks for /doctor. These are the tripwires that
|
|
2
|
+
// would have caught the node-20 pod outage: node:sqlite silently absent, so
|
|
3
|
+
// recall ran 542 turns over 16 days with zero seeds and 87% of reviewer runs
|
|
4
|
+
// lost their mutations — and nothing surfaced it. Every check is synchronous,
|
|
5
|
+
// fast, never throws, and never spawns a model.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const CONFIG_DIR = require("../config-dir");
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const METRICS_TAIL_BYTES = 256 * 1024;
|
|
13
|
+
const LOCK_HELD_WARN_MS = 5 * 60 * 1000;
|
|
14
|
+
const DISK_WARN_BYTES = 1024 * 1024 * 1024;
|
|
15
|
+
const DISK_FAIL_BYTES = 200 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
function sqliteAvailable() {
|
|
18
|
+
try { require("node:sqlite"); return true; } catch (_) { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Last ~N bytes of a JSONL file as parsed objects (partial first line dropped).
|
|
22
|
+
function tailJsonl(file, maxBytes = METRICS_TAIL_BYTES) {
|
|
23
|
+
let fd = null;
|
|
24
|
+
try {
|
|
25
|
+
const stat = fs.statSync(file);
|
|
26
|
+
fd = fs.openSync(file, "r");
|
|
27
|
+
const size = Math.min(stat.size, maxBytes);
|
|
28
|
+
const buf = Buffer.alloc(size);
|
|
29
|
+
fs.readSync(fd, buf, 0, size, stat.size - size);
|
|
30
|
+
const lines = buf.toString("utf-8").split("\n").filter(Boolean);
|
|
31
|
+
if (stat.size > maxBytes) lines.shift();
|
|
32
|
+
return lines.map((l) => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean);
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return [];
|
|
35
|
+
} finally {
|
|
36
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function substrateCheck() {
|
|
41
|
+
if (sqliteAvailable()) {
|
|
42
|
+
return { ok: true, label: "Memory substrate (node:sqlite)", detail: "available — FTS matching, recall graph, episodic index active" };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
label: "Memory substrate (node:sqlite)",
|
|
47
|
+
detail: `missing on Node ${process.version} — recall graph, FTS and episodic memory DISABLED; lexical keyword fallback only`,
|
|
48
|
+
action: "Run on Node 24+ (Dockerfile.base must build FROM node:24-slim). Memory stays degraded until then.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function graphCheck() {
|
|
53
|
+
if (!sqliteAvailable()) return null; // substrate check already reports the cause
|
|
54
|
+
try {
|
|
55
|
+
const graph = require("./recall/graph");
|
|
56
|
+
if (!graph.available()) {
|
|
57
|
+
return { ok: true, warn: true, label: "Recall graph", detail: "unavailable", action: "Graph module failed to open its database — check RECALL_GRAPH_DB path/permissions." };
|
|
58
|
+
}
|
|
59
|
+
const s = graph.stats();
|
|
60
|
+
return { ok: true, label: "Recall graph", detail: `${s.nodes} nodes, ${s.edges} edges` };
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return { ok: true, warn: true, label: "Recall graph", detail: `check failed: ${e.message}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function recallPipelineCheck(now = Date.now()) {
|
|
67
|
+
const label = "Recall pipeline (24h)";
|
|
68
|
+
try {
|
|
69
|
+
const metrics = require("./recall/metrics");
|
|
70
|
+
const cutoff = now - DAY_MS;
|
|
71
|
+
const lines = tailJsonl(metrics.LOG_FILE);
|
|
72
|
+
const turns = lines.filter((r) => r.engine && Date.parse(r.ts || "") >= cutoff);
|
|
73
|
+
const live = turns.filter((r) => !r.gated);
|
|
74
|
+
if (!live.length) {
|
|
75
|
+
return { ok: true, warn: true, label, detail: "no recall turns in the last 24h (idle, or classic engine)" };
|
|
76
|
+
}
|
|
77
|
+
const seeded = live.filter((r) => (r.seeds || []).length > 0).length;
|
|
78
|
+
const injected = live.filter((r) => (r.kept || []).length > 0).length;
|
|
79
|
+
const walkerFailed = live.filter((r) => r.walker === "failed").length;
|
|
80
|
+
const detail = `${live.length} turns, ${seeded} seeded, ${injected} injected memories, ${walkerFailed} walker failures`;
|
|
81
|
+
if (live.length >= 10 && seeded === 0) {
|
|
82
|
+
return { ok: false, label, detail: `${detail} — matching is returning NOTHING`, action: "Recall is running blind. Check the substrate above and the packs/entities indexes." };
|
|
83
|
+
}
|
|
84
|
+
if (live.length >= 10 && injected === 0) {
|
|
85
|
+
return { ok: true, warn: true, label, detail: `${detail} — nothing injected all day`, action: "Seeds exist but nothing survives to injection — check walker health and the relevance gate." };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, label, detail };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function reviewerCheck(now = Date.now()) {
|
|
94
|
+
const label = "Memory reviewer (24h)";
|
|
95
|
+
try {
|
|
96
|
+
const metrics = require("./recall/metrics");
|
|
97
|
+
const cutoff = now - DAY_MS;
|
|
98
|
+
const runs = tailJsonl(metrics.LOG_FILE).filter((r) => r.reviewer && Date.parse(r.ts || "") >= cutoff);
|
|
99
|
+
if (!runs.length) {
|
|
100
|
+
return { ok: true, warn: true, label, detail: "no reviewer runs in the last 24h" };
|
|
101
|
+
}
|
|
102
|
+
const failed = runs.filter((r) => r.reviewer.status === "failed");
|
|
103
|
+
const deferred = runs.filter((r) => r.reviewer.status === "deferred").length;
|
|
104
|
+
const lastFail = failed.length ? failed[failed.length - 1].reviewer.code || "" : "";
|
|
105
|
+
const okRate = (runs.length - failed.length) / runs.length;
|
|
106
|
+
const detail = `${runs.length} runs, ${failed.length} failed${deferred ? `, ${deferred} deferred` : ""}${lastFail ? ` (last error: ${lastFail})` : ""}`;
|
|
107
|
+
if (okRate < 0.5) {
|
|
108
|
+
return { ok: false, label, detail, action: "Most memory captures are being lost — check the mutation lock and pending review mutations below." };
|
|
109
|
+
}
|
|
110
|
+
if (okRate < 0.9) {
|
|
111
|
+
return { ok: true, warn: true, label, detail, action: "Reviewer failure rate is elevated — check the mutation lock below." };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, label, detail };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mutationLockCheck() {
|
|
120
|
+
const label = "Memory mutation lock";
|
|
121
|
+
try {
|
|
122
|
+
const { inspectLock } = require("./memory-mutation-queue");
|
|
123
|
+
const lock = inspectLock();
|
|
124
|
+
if (!lock.held) return { ok: true, label, detail: "free" };
|
|
125
|
+
const mins = Math.round(lock.ageMs / 60000);
|
|
126
|
+
const holder = lock.owner ? `pid ${lock.owner.pid}${lock.owner.id ? ` (${String(lock.owner.id).slice(0, 8)})` : ""}` : "unknown owner";
|
|
127
|
+
if (lock.stale) {
|
|
128
|
+
return { ok: true, warn: true, label, detail: `stale — holder gone (${holder}, ${mins}m old); breaks on next mutation` };
|
|
129
|
+
}
|
|
130
|
+
if (lock.ageMs > LOCK_HELD_WARN_MS) {
|
|
131
|
+
return { ok: true, warn: true, label, detail: `held ${mins}m by live ${holder}`, action: "A dream/review may be wedged holding the lock — if this persists, investigate that process." };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, label, detail: `held by ${holder} (active mutation)` };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function deferredMutationsCheck() {
|
|
140
|
+
const label = "Pending review mutations";
|
|
141
|
+
try {
|
|
142
|
+
const { listDeferredDecisions } = require("./pack-review");
|
|
143
|
+
const pending = listDeferredDecisions();
|
|
144
|
+
if (!pending.length) return { ok: true, label, detail: "none" };
|
|
145
|
+
return {
|
|
146
|
+
ok: true, warn: true, label,
|
|
147
|
+
detail: `${pending.length} computed decision(s) never applied (lock timeouts)`,
|
|
148
|
+
action: `Inspect ${path.join(CONFIG_DIR, "pending-review-mutations.jsonl")} — re-apply what still matters, then clear it.`,
|
|
149
|
+
};
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function corpusCheck() {
|
|
156
|
+
const label = "Memory corpus";
|
|
157
|
+
try {
|
|
158
|
+
const packs = require("./packs").listPacks().length;
|
|
159
|
+
const entities = require("./entities").listEntities().length;
|
|
160
|
+
const lessonsLib = require("./lessons");
|
|
161
|
+
const lessonCount = lessonsLib.listLessons().length;
|
|
162
|
+
const overCap = lessonCount > lessonsLib.MAX_LESSONS;
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
warn: overCap,
|
|
166
|
+
label,
|
|
167
|
+
detail: `${packs} packs, ${entities} entities, ${lessonCount}/${lessonsLib.MAX_LESSONS} lessons${overCap ? " (over cap)" : ""}`,
|
|
168
|
+
action: overCap ? "Lessons exceed the always-loaded cap — run `open-claudia dream` to consolidate." : "",
|
|
169
|
+
};
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function diskCheck() {
|
|
176
|
+
const label = "Disk space (config dir)";
|
|
177
|
+
if (typeof fs.statfsSync !== "function") return null;
|
|
178
|
+
try {
|
|
179
|
+
const s = fs.statfsSync(CONFIG_DIR);
|
|
180
|
+
const free = Number(s.bsize) * Number(s.bavail);
|
|
181
|
+
const gb = (free / (1024 * 1024 * 1024)).toFixed(1);
|
|
182
|
+
if (free < DISK_FAIL_BYTES) {
|
|
183
|
+
return { ok: false, label, detail: `${gb} GB free`, action: "Nearly out of disk — memory writes will start failing. Free space now." };
|
|
184
|
+
}
|
|
185
|
+
if (free < DISK_WARN_BYTES) {
|
|
186
|
+
return { ok: true, warn: true, label, detail: `${gb} GB free`, action: "Low disk — consider clearing old transcripts/artifacts." };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, label, detail: `${gb} GB free` };
|
|
189
|
+
} catch (_) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The full memory section, in report order. Never throws.
|
|
195
|
+
function memoryChecks(options = {}) {
|
|
196
|
+
const now = options.now || Date.now();
|
|
197
|
+
const checks = [];
|
|
198
|
+
for (const check of [
|
|
199
|
+
substrateCheck(),
|
|
200
|
+
graphCheck(),
|
|
201
|
+
recallPipelineCheck(now),
|
|
202
|
+
reviewerCheck(now),
|
|
203
|
+
mutationLockCheck(),
|
|
204
|
+
deferredMutationsCheck(),
|
|
205
|
+
corpusCheck(),
|
|
206
|
+
diskCheck(),
|
|
207
|
+
]) {
|
|
208
|
+
if (check) checks.push(check);
|
|
209
|
+
}
|
|
210
|
+
return checks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { memoryChecks, sqliteAvailable, tailJsonl };
|
package/core/doctor.js
CHANGED
|
@@ -160,6 +160,11 @@ function runDoctorChecks(options = {}) {
|
|
|
160
160
|
|
|
161
161
|
checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
|
|
162
162
|
checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
|
|
163
|
+
try {
|
|
164
|
+
checks.push(...require("./doctor-memory").memoryChecks());
|
|
165
|
+
} catch (e) {
|
|
166
|
+
checks.push({ ok: true, warn: true, label: "Memory health", detail: `checks failed to run: ${redactSensitive(e.message)}` });
|
|
167
|
+
}
|
|
163
168
|
return checks;
|
|
164
169
|
}
|
|
165
170
|
|