fix(plugin): consolidate local runtime hardening - #2286
Conversation
…ermes chat detection The #1915 pattern used `(?:\s+\S+)*` — a PCRE non-capturing group. `pgrep -f` on Linux compiles patterns with glibc's POSIX ERE, which has no non-capturing groups, so every call failed with: pgrep: regex error: Invalid preceding regular expression `isHermesChatRunning()` swallows the error and returns `false`, so the daemon viewer was permanently stuck on "disconnected" even with a `hermes chat` session attached. The JS unit test passed because JavaScript RegExp accepts `(?:…)` — the proxy was not faithful for ERE. Replace the non-capturing group with a plain capturing group `(\s+\S+)*`, which is valid in both POSIX ERE and JavaScript RegExp, and update the doc comment to warn that the pattern must stay within ERE. Add a regression test that runs the real `pgrep` binary and asserts it does not exit 2 (regex syntax error), so a PCRE-ism can never silently return.
Bridge persists config.yaml with API keys masked to __memos_secret__
via maskSecrets() and strips empty secrets from patches via
stripEmptySecrets(), but nothing re-reads the real value back. On
daemon restart, loadConfig() treats the mask as the literal API key,
every LLM call fails auth, and the bridge restart-loops with
lastOkAt: null and skill.crystallize stuck.
Make resolveConfig() (the single choke point for both disk-loaded
and in-memory patched configs) walk SECRET_FIELD_PATHS after
pruneUnknown and before deepMerge:
- ${VAR} references resolve from process.env when the name matches
the allowlist ^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$; other names emit
a warning and stay untouched.
- __memos_secret__ / empty apiKey leaves fall back to LLM_API_KEY
(or EMBEDDING_API_KEY for embedding.apiKey), then to
OPENCODE_GO_API_KEY / OPENCODE_ZEN_API_KEY for LLM-class fields
only. Embedding never inherits an LLM provider's key.
- Hub tokens (hub.teamToken, hub.userToken) require an explicit
${VAR} — no path-based env convention.
- Real values pass through unchanged; the caller's raw config
object is never mutated (resolution runs on the pruneUnknown copy).
Read-side only: on-disk write stays masked, so the security posture
of maskSecrets() is preserved.
Adds 10 unit tests under tests/unit/config/resolve-secret-env.test.ts
covering ${VAR} expansion, mask sentinel resolution, empty-string
fallback, per-path env conventions (embedding vs LLM channel
isolation), hub token ${VAR} path, allowlist enforcement,
non-mutation of the raw config, and negative cases (no env → mask
retained; unset ${VAR} → literal preserved; real values untouched).
Fixes #2245
Address 4 findings from the open-code-review pass on PR #2246: 1. hub.teamToken / hub.userToken are now resolved from the environment when masked with __memos_secret__ or written as empty strings. The previous `if (leaf !== "apiKey") continue` short-circuit silently perpetuated the original bug for hub tokens. 2. Emit a warning when a secret leaf references an env var that is not set (both the explicit ${VAR} form and the mask/empty form). Without this, a user who writes `apiKey: ${MY_API_KEY}` and forgets to export MY_API_KEY sees auth failures with no actionable log line. 3. Restrict the OPENCODE_GO_API_KEY / OPENCODE_ZEN_API_KEY generic fallback to the primary llm.apiKey. Per-component overrides (l3Llm.apiKey, skillEvolver.apiKey) and non-LLM secrets (embedding.apiKey, hub.*Token) must never silently borrow an unrelated provider's key — that causes cross-provider auth failures and unexpected billing when those components are pointed at a different provider than the shared llm settings. 4. Use an explicit `traversalOk` flag when walking SECRET_FIELD_PATHS so a partial traversal cannot leave `cursor` pointing at a shallower valid intermediate node that would then pass the isPlainObject check and cause `leaf` to be looked up on the wrong object. Today every entry is 2 levels deep so the bug is latent, but the flag makes the intent explicit and future-proofs against deeper paths being added. Env var derivation for masked/empty leaves now uses a camel→SNAKE transform on the last two path segments so every SECRET_FIELD_PATHS entry is resolvable by convention: embedding.apiKey → EMBEDDING_API_KEY llm.apiKey → LLM_API_KEY l3Llm.apiKey → L3_LLM_API_KEY skillEvolver.apiKey → SKILL_EVOLVER_API_KEY hub.teamToken → HUB_TEAM_TOKEN hub.userToken → HUB_USER_TOKEN Tests updated to cover the new hub-token resolution, the tightened fallback scope, and both warning cases. All 76 config tests pass; tsc --noEmit clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fig keys llm.maxTokens and llm.headers are read at runtime (client.ts reads config.maxTokens, providers spread config.headers) but were absent from DEFAULT_CONFIG and LlmSchema/SkillEvolverSchema, so every boot logged "unknown config key 'llm.maxTokens'" and "unknown config key 'llm.headers.<key>'" (pruneUnknown recursed into the empty headers slot and warned per user key). Add both to defaults + schema, and teach pruneUnknown that an empty-object default slot is a free-form map that must be kept as-is, eliminating the per-key warnings. Adds a regression test covering acceptance, defaults, range validation and the free-form-map warning suppression.
…ed-slot defaults to 4096 Addresses OpenCodeReview feedback on #2248: - The l3Llm and skillEvolver client builders constructed their clients with explicit field picks that dropped maxTokens and headers — the config keys declared by the previous commit were inert at runtime (effective cap was the hard-coded DEFAULT_MAX_TOKENS=1024 in client.ts regardless of config). - Add maxTokens+headers to DedicatedLlmConfig and pass both through in the reflectLlm (skillEvolver) and l3Llm builders so configured values actually reach the provider request. - Add headers to SkillEvolverSchema (l3Llm/skillEvolver slots) so custom HTTP headers are accepted on those slots, mirroring the llm slot. - Raise l3Llm/skillEvolver maxTokens defaults from 1024 to 4096: L3 world- model bodies span multiple L2 policies/evidence traces, and crystallized skill bodies include invocation guides + procedure steps — 1024 tokens risks silent truncation on both workloads (both slots already assume 60s timeouts, implying heavier calls). - Update config tests: default assertions now pin 4096, plus new coverage for l3Llm.maxTokens and headers on both dedicated slots.
core.shutdown() awaited startupRecoveryPromise with no timeout. With a large dirty episode and a slow/flaky LLM, the recovery reflect chain can take minutes, holding shutdown hostage until the systemd kill timer (observed 15 Aug 2026: SIGTERM 08:00:23 -> SIGKILL 08:10:23, 10-minute stop-sigterm wedge). Recovery is resumable: dirty episodes carry rewardDirty.failedAttempts and the periodic rescore re-runs them, so nothing is lost by proceeding after a short grace. The 15s bound still covers the fast init->shutdown SQLite race (issue #1808) that the wait was introduced for.
…s floor 100 Addresses remaining OpenCodeReview feedback on #2248: headers was declared on SkillEvolverSchema but absent from the l3Llm/skillEvolver defaults, so setting those keys in YAML still warned unknown config key and bypassed the pruneUnknown free-form-map shortcut; maxTokens floor raised 16 to 100 to match the documented deepseek-v4-flash constraint; dedicated-slot headers now asserted warning-free, defaults pinned, out-of-range regression pinned at 50.
…ting defaultDraftValidator threw skill.crystallize.invalid: missing summary / missing steps whenever the LLM returned valid JSON without those fields (observed with deepseek-v4-flash), flooding bridge logs every 5-10s and stalling the crystallizer queue. It now repairs the draft instead: - missing summary: derived from first step body/title, then displayTitle, then name, then a static placeholder; capped at 200 chars - missing steps: a single Execute-the-fix step generated from the summary; only throws when nothing at all can be derived - missing name: still rejected (normaliseDraft already supplies a name fallback on the LLM path, so this only guards direct validator use) Fixes #2143
….dumps (#2255) memos_search / memos_get / memos_timeline / memos_skill_list / memos_environment / memos_skill_get in the Hermes memory provider serialized their tool results back to the host LLM with the default ensure_ascii=True, which escaped every non-ASCII code point (notably Chinese memory content) to \uXXXX. The DB stored the correct UTF-8; only the wire JSON was mangled. This inflated tokens for Chinese users and made retrieval results unreadable when debugging. Add ensure_ascii=False to every json.dumps inside handle_tool_call (both the tool-result branches called out in the issue and the error/fallthrough branches, for a uniform pattern that matches the other json.dumps calls in this file at L742/1015/1021 that already pass the flag). Add HandleToolCallEnsureAsciiTests to apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py with 8 regression cases (one per affected tool + both branches of memos_environment) that assert the returned JSON contains raw Chinese characters, contains no "\u" escape marker, and round-trips through json.loads. Verified the tests fail without the fix and pass with it; ruff check + ruff format clean; full test file 35/35 green. Fixes #2255
Address PR #2256 OCR review findings: 1. Add `test_memos_get_world_model_returns_utf8_chinese` to cover the `world_model` branch of `memos_get` (routed via `memory.get_world`). Without this test, an accidental removal of `ensure_ascii=False` from the world_model branch would go undetected — the previous tests only exercised the `trace` and `policy` kinds. 2. Remove the fragile `assertNotIn("\\u", raw)` guard from all HandleToolCallEnsureAsciiTests cases. That check was prone to false positives (any legitimate value containing a backslash followed by `u`, e.g. a Windows path, would fail the test) while providing weaker coverage than the `assertIn(_CH_..., raw)` + `json.loads` field equality assertions already present. Add a header comment explaining why the literal-in-raw check is the robust regression guard. Also add a parsed field-equality assertion to `test_memos_skill_list_returns_utf8_chinese` for parity with the other cases. All 36 tests in tests/python/test_hermes_provider_pipeline.py pass.
…ermes chat detection (#2192) ## Summary Fixes the Hermes chat process detection regex in the memos-local-plugin. The pattern introduced for #1915 used a JavaScript/PCRE non-capturing group, but `pgrep -f` on Linux compiles patterns with glibc's POSIX ERE engine. The invalid pattern made every detection call fail and left the daemon viewer stuck on `"disconnected"`. ## Root cause The unit helper compiled the same string with JavaScript's `RegExp`, which accepts `(?:...)`. That verified matching behavior but not whether the pattern passed to `pgrep` was valid ERE. ## Fix - Build the runtime pattern with POSIX `[[:space:]]` character classes and capturing groups only. - Build the JavaScript test pattern from the same command grammar using equivalent `\\s` / `\\S` tokens. - Require `chat` to be a complete argv token, preventing false positives such as `hermes chat-server`. - Run the real `pgrep` binary on Linux and require an exit status of 0 or 1, so syntax errors and missing executables cannot pass silently. - Merge the latest `main` into the contributor branch. ## Verification - Old pattern reproduction: `pgrep` exits 2 with a regex compilation error. - `vitest run tests/unit/bridge/hermes-process.test.ts tests/unit/bridge-status.test.ts`: 18 passed, 1 Linux-only test skipped on macOS. - `npm run lint`: passed. - `npm run test:unit`: 155 files passed; 1297 tests passed, 2 skipped. - `npm run build`: passed. ## Notes The Linux-only `pgrep` regression executes in CI. No runtime dependencies or generated `dist/` files are added.
## Description Archive active Skills that remain below the retrieval ETA threshold after a configurable period of retrieval inactivity. This change: - adds `algorithm.skill.idleArchiveMs` (30 days by default); - uses `lastUsedAt ?? createdAt` as the idle baseline; - queries eligible candidates directly in SQLite and drains 500-row batches oldest-first; - runs the scan from the existing lifecycle tick without adding a timer; - preserves the public four-argument `shouldArchiveIdle` API; - emits structured lifecycle status events/logs and documents the behavior. No new dependencies. Related Issue (Required): Fixes #2144 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] Documentation update ## How Has This Been Tested? - [x] Unit Test - [x] Test Script Or Test Steps (please provide) - [ ] Pipeline Automated API Test (not applicable) Commands and results: - Focused lifecycle/storage/config/OpenClaw integration suite: 67/67 passed. - Broader skill/config/storage/pipeline/OpenClaw suite: 257/257 passed. - Full unit suite: 1302 passed, 1 skipped. Two repository-layout tests failed only because the remote test copy was outside the normal `apps/memos-local-plugin` layout and could not resolve repository-level workflow files. - `tsc --noEmit`: passed. - `tsc -p tsconfig.build.json`: passed. - `git diff --check`: passed. Additional verification: - a 501-candidate backlog test verifies that one lifecycle tick drains multiple batches; - mutation checks verify the suite catches wrong idle baselines, an inclusive ETA boundary, disabled lifecycle invocation, single-batch starvation, and missing `recordUse` persistence. `make format` could not start locally because Poetry is unavailable. This change is confined to the TypeScript plugin and does not modify Python files. ## Checklist - [x] I have performed a self-review of my own code | 我已自行检查了自己的代码 - [x] I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释 - [x] I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常 - [x] MemOS-Docs issue/PR is not applicable; plugin-local documentation is updated - [x] I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用) - [ ] I have mentioned the person who will review this PR | Maintainer assignment requested after submission ## Reviewer Checklist - [x] closes #2144 - [ ] Made sure Checks passed - [x] Tests have been provided
Co-authored-by: asorry75 <asorry75@users.noreply.github.com>
…d on daemon restart — (#2246) ## Description Fixed #2245 — masked apiKey never resolved on daemon restart. The bridge persists `config.yaml` with API keys masked to `__memos_secret__` via `maskSecrets()` and strips empty secrets from patches via `stripEmptySecrets()`, but nothing re-reads the real value back. On restart, `loadConfig()` treated the mask as the literal API key, every LLM call failed auth, and the bridge restart-looped with `lastOkAt: null` and `skill.crystallize.failed ... timed out after 120000 ms`. Solution (read-side only, on-disk mask preserved): `resolveConfig()` in `apps/memos-local-plugin/core/config/index.ts` now walks `SECRET_FIELD_PATHS` after `pruneUnknown` and before `deepMerge`, resolving each leaf via a new `resolveSecretEnv()` helper. `${VAR}` references expand from `process.env` when the name matches the allowlist `^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)## Description (non-allowlisted names emit a warning and stay untouched). `__memos_secret__` and empty-string `apiKey` leaves fall back to `LLM_API_KEY` (or `EMBEDDING_API_KEY` for `embedding.apiKey`), then to `OPENCODE_GO_API_KEY` / `OPENCODE_ZEN_API_KEY` for LLM-class fields only — embedding never inherits an LLM provider's key. Hub tokens (`hub.teamToken`, `hub.userToken`) require an explicit `${VAR}`. Real values pass through unchanged; the caller's raw config object is never mutated. Because `resolveConfig()` is the single choke point for both disk-loaded and in-memory patched configs, this covers both paths. Tests: added `tests/unit/config/resolve-secret-env.test.ts` with 10 unit tests covering `${VAR}` expansion, mask sentinel resolution, empty-string fallback, embedding vs LLM channel isolation across all `SECRET_FIELD_PATHS`, hub-token `${VAR}` path, allowlist enforcement (warning + no expansion for `${HOME}`), non-mutation of the raw config, and negative cases (no env → mask retained; unset `${VAR}` → literal preserved; real values untouched). `npx vitest run tests/unit/config` → 5 files / 71 tests passed. `npx tsc -p tsconfig.json --noEmit` clean. Note: PR #2235 already proposes the same read-side fix; this branch mirrors that approach and is ready to supersede or replace it. Reviewers: @whipser030, @hijzy. Related Issue (Required): Fixes #2245 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Refactor (does not change functionality, e.g. code style improvements, linting) - [ ] Documentation update ## How Has This Been Tested? Automated tests are pending. - [ ] Unit Test - [ ] Test Script Or Test Steps (please provide) - [ ] Pipeline Automated API Test (please provide) ## Checklist - [ ] I have performed a self-review of my own code - [ ] I have commented my code in hard-to-understand areas - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have created related documentation issue/PR in [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) (if applicable) - [x] I have linked the issue to this PR (if applicable) - [x] I have mentioned the person who will review this PR @whipser030, @hijzy please review this PR. ## Reviewer Checklist - [x] closes #2245 - [ ] Made sure Checks passed - [ ] Tests have been provided
…fig keys (#2248) ## Summary `llm.maxTokens` and `llm.headers` are read at runtime (LLM client resolves `config.maxTokens` with a 1024 fallback; every provider spreads `config.headers` into requests) but are absent from `DEFAULT_CONFIG` and the config schema. Every boot logs `unknown config key 'llm.maxTokens'`, and once `headers` is present, `pruneUnknown()` recurses into the empty default slot and warns for **every** user header key (`unknown config key 'llm.headers.User-Agent'`). This PR declares both keys as first-class config, adds defaults, and fixes `pruneUnknown()` so empty-object default slots are treated as free-form maps. ## Change - `core/config/defaults.ts` — add `maxTokens: 1024` + `headers: {}` to the `llm` tree; add `maxTokens: 1024` to `skillEvolver` and `l3Llm` (both share `SkillEvolverSchema`). - `core/config/schema.ts` — declare `maxTokens` (range 16–131072, default 1024) + `headers` (`Record<string, string>`) in `LlmSchema`; declare `maxTokens` in `SkillEvolverSchema`. - `core/config/index.ts` — `pruneUnknown()`: an empty-object default slot is a free-form map (`Record<string, string>`), so keep the whole user object as-is instead of recursing and warning per key. - `tests/unit/config/llm-max-tokens-headers.test.ts` — new regression suite: acceptance without warnings, defaults, range validation, non-string header rejection, unrelated-field preservation. ## Tests - `npx vitest run tests/unit/config tests/unit/llm` → **148 passed (10 files)** - `npx tsc -p tsconfig.json --noEmit` → clean (exit 0) - New file: 7 tests (acceptance / default maxTokens / default headers / skillEvolver maxTokens / range rejection / header type rejection / unrelated fields) ## Related Fixes #2247 ## Environment - **Plugin version:** monorepo main (b4cc9bc) - **Runtime:** Node 22, npm 10 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) ## How Tested 1. `npx vitest run tests/unit/config tests/unit/llm` — 148 passed 2. `npx tsc -p tsconfig.json --noEmit` — 0 errors 3. Manual: `resolveConfig({ llm: { maxTokens: 2048, headers: { "User-Agent": "test" } } })` returns both values with **zero warnings**; `resolveConfig({})` yields `maxTokens: 1024`, `headers: {}` ## Checklist - [x] I have read the CONTRIBUTING guidelines - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation (config template) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally
## Problem `core.shutdown()` awaited `startupRecoveryPromise` with **no timeout**. With a large dirty episode and a slow/flaky LLM, the startup-recovery reflect chain (up to 163 sequential LLM calls at ~2min each) can take minutes — holding shutdown hostage until the systemd kill timer fires. Observed 15 Aug 2026 on a production bridge: SIGTERM at 08:00:23, daemon stuck mid-recovery, systemd `TimeoutStopSec=600` expired, SIGKILL at 08:10:23. A 10-minute stop-sigterm wedge from a single unguarded await. ## Fix Wrap the recovery wait in the existing `withTimeout()` helper (15s): ```ts await withTimeout(startupRecoveryPromise, 15_000, "startup_recovery_shutdown_timeout"); ``` **Why 15s is safe:** - The wait was introduced for issue #1808 to prevent a fast `init → shutdown` race closing SQLite mid-flush. A 15s grace still covers that (recovery of 0–1 episodes finishes in ms). - Recovery is **resumable by design**: dirty episodes carry `rewardDirty.failedAttempts`, and the periodic 10-minute rescore re-runs them. Proceeding after the grace loses no data. - After the timeout, `handle.shutdown()` detaches subscribers and the daemon's `process.exit(0)` fires — lingering LLM calls can no longer hold the process hostage. ## Verification - `tsc --noEmit` clean - Full unit suite: 168 files / 1365 tests passed (incl. the issue #1808 recovery-stall test) - Production companion change: `TimeoutStopSec` tightened 600s → 90s on the systemd unit (ops-side belt-and-braces; with this fix graceful shutdown completes in ~20s)
… trace reads
Problem
-------
The daemon froze solid on installs with a large `traces` table: HTTP requests
were accepted by the kernel backlog but never answered, boot took 40-60s of
near-100% CPU, and any liveness watchdog restart-looped the process forever
(300+ restarts/day observed in production). Every doomed generation re-ran
the scan at boot and was killed mid-scan, making the storm self-sustaining.
Root cause (CPU-profiled, 28% of all samples in one statement)
-------
`traces.list({limit:1})` -- used by `latestTraceTs()` (3x per
`/api/v1/health` request) and by the pipeline's recent-events replay at
bootstrap -- issues `SELECT ... FROM traces ORDER BY ts DESC, id DESC
LIMIT 1`. No `traces` index leads with bare `ts` (`EXPLAIN QUERY PLAN`:
`SCAN traces` + `USE TEMP B-TREE FOR ORDER BY`), so each call was a full
table scan + sort. better-sqlite3 runs statements synchronously on the JS
event loop, so the scan blocked ALL request handling while it ran.
Fix
---
- 013-traces-ts-index.sql: `CREATE INDEX IF NOT EXISTS idx_traces_ts ON
traces(ts DESC, id DESC)` -- turns the lookup into an index seek.
- migrator.ts: same tableExists guard as 012 for partial test schemas, plus a
release-train heal: DBs migrated by the other train carry schema_migrations
rows whose VERSION numbers collide under different NAMES (observed: 13 =
'skill-repair-origin', 14 = 'episode-outcome'), which silently skipped any
same-numbered migration from this build. Additive, guarded migrations now
still run under such collisions and repair their bookkeeping row via upsert;
all others keep the conservative skip behaviour.
- migrator.test.ts: regression tests for both behaviours (8/8 pass).
Validation
----------
- EXPLAIN after: `SCAN traces USING INDEX idx_traces_ts`; newest-trace lookup
~700ms -> ~0.7ms warm (>1000x); index build ~1s per 100k rows.
- Live sandbox reproduction (2.0.15 build, prod-sized DB copy): daemon that
previously never answered a single request in 150s served 200 OK within 6s
of boot and kept serving.
- Production cutover: pipeline.ready 60s+ -> <1s; health 200 OK @ 205ms;
restart storm stopped (was 321 restarts that day, zero since).
- tests/unit/storage/: 82/82 pass; tsc clean for storage/*.
Commit-message-only note: no runtime code paths changed other than schema;
the migration is additive and idempotent.
…ting (#2253) ## Summary `defaultDraftValidator` in `core/skill/crystallize.ts` throws `skill.crystallize.invalid: missing summary` / `missing steps` whenever the LLM returns valid JSON that omits those fields. With deepseek-v4-flash this happens routinely, flooding bridge logs with 10+ `ERROR skill.crystallize.failed` messages per minute and stalling the crystallizer queue (which in turn delays other RPCs like `memory.search` waiting on the queue to drain). This PR makes the validator **repair** such drafts instead of rejecting them: - **Missing summary** → auto-generated from the richest available field, in priority order: first step body → first step title → `displayTitle` → `name` → static placeholder `"skill procedure"`. Capped at 200 chars. Never throws for a missing/empty summary. - **Missing/empty steps** → a single `"Execute the fix"` step generated from the summary (`title` + `body`, capped at 2000 chars). Only throws when nothing at all can be derived. - **Missing name** → still rejected (`skill.crystallize.invalid: missing name`). On the LLM path this is unreachable anyway — `normaliseDraft()` already substitutes `skill_<policy_id>` — so the check remains purely a guard for direct validator use. ## Change - `core/skill/crystallize.ts` — `defaultDraftValidator` rewritten: strict → lenient repair (uses `||` not `??` — LLM JSON emits empty strings, and `??` only falls through on null/undefined). - `tests/unit/skill/crystallize.test.ts` — the old "rejects drafts that the validator flags as invalid" test used `{ steps: [], summary: "" }`, which is exactly the case this PR now repairs; converted into an end-to-end regression asserting the draft crystallizes with auto-generated summary + steps. - `tests/unit/skill/crystallize-validator.test.ts` — new focused suite (8 tests): full-draft passthrough, never-throws-for-missing-summary, summary fallback chain (step body → displayTitle → name), 200-char cap, empty-string (`||`) semantics, single-step auto-generation, missing-name rejection. ## Tests - `npx vitest run tests/unit/skill` → **54 passed (10 files)** - `npx tsc -p tsconfig.json --noEmit` → clean (exit 0) - The repair logic is the same shape already proven in production (local Hermes CT100 ran this validator for ~2 weeks; crystallizer backlog drained, zero `missing summary` rejections since). ## Related - **Fixes #2143** — the tracked issue for this failure mode (filed 2026-07-22). - PR #2064 (v2.0.23) addressed the separate empty-response mode; this PR covers the valid-JSON-without-summary mode it did not. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) ## How Has This Been Tested? - [x] Unit tests (`npx vitest run tests/unit/skill` — 54 passed) - [x] Type-check (`tsc --noEmit` clean) - [x] Production-proven shape (the lenient fallback chain has run on Hermes CT100 since ~1 Aug 2026; `skill.crystallize.failed` missing-summary floods dropped to zero) ## Checklist - [x] I have read the CONTRIBUTING guidelines - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally
…s (Hermes adapter miss (#2256) ## Description Fixed #2255: Chinese memory content returned by the Hermes adapter's memos_* tools was being serialized to the host LLM as `\uXXXX` escapes because every `json.dumps(...)` inside `MemTensorProvider.handle_tool_call` (apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py) relied on Python's default `ensure_ascii=True`. Data in the DB was always correct; only the on-wire JSON was mangled, inflating tokens and making retrieval unreadable for Chinese users. Added `ensure_ascii=False` to every `json.dumps` call inside `handle_tool_call` — both the tool-result branches called out in the issue (memos_search, memos_get trace/policy/world_model, memos_timeline, memos_skill_list, memos_environment list+query, memos_skill_get) and the error / fallthrough branches. The uniform pattern matches the three `json.dumps` calls elsewhere in the same file (L742/1015/1021) that already set the flag. Added `HandleToolCallEnsureAsciiTests` to `apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py` — 8 regression cases that assert each affected tool returns raw Chinese characters, contains no `\u` escape marker, and round-trips through `json.loads`. Confirmed the tests fail without the fix and pass with it. Full test file `test_hermes_provider_pipeline.py` is 35/35 green (27 pre-existing + 8 new); `ruff check` and `ruff format --check` both clean on the touched files. Categorized as an opsp Bug quick-fix (no proposal/spec/design), one-line change per return statement. Committed on bugfix/autodev-2255-20260816025022257 and pushed to origin. Reviewers: @whipser030, @hijzy. Related Issue (Required): Fixes #2255 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Refactor (does not change functionality, e.g. code style improvements, linting) - [ ] Documentation update ## How Has This Been Tested? Automated tests are pending. - [ ] Unit Test - [ ] Test Script Or Test Steps (please provide) - [ ] Pipeline Automated API Test (please provide) ## Checklist - [ ] I have performed a self-review of my own code - [ ] I have commented my code in hard-to-understand areas - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have created related documentation issue/PR in [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) (if applicable) - [x] I have linked the issue to this PR (if applicable) - [x] I have mentioned the person who will review this PR @whipser030, @hijzy please review this PR. ## Reviewer Checklist - [x] closes #2255 - [ ] Made sure Checks passed - [ ] Tests have been provided
… trace reads (#2284) ## Summary Fixes a production daemon freeze/restart-storm caused by an unindexed newest-first trace read that blocked the Node event loop. Adds one index migration plus a migrator heal for release-train version collisions. ## Problem On an install whose `traces` table reached ~30k rows (~235 MB with embedding blobs + tool-call JSON), the `memos-local-plugin` daemon: - never answered HTTP requests after boot (TCP accepted into the kernel backlog, zero bytes served) - spent 40–60s per boot at ~100% CPU inside a single synchronous SQLite call - was restarted every ~3 minutes by our liveness watchdog — **321 restarts in one day**, self-sustaining because each doomed generation re-ran the scan at boot and was killed mid-scan ## Root cause (CPU-profiled) A `--cpu-prof` capture showed **28% of all samples in one statement**: `all() @ connection.js` under two call paths: 1. `/api/v1/health` → `latestTraceTs()` → `traces.list({ limit: 1 })` (**three calls per health request**) 2. bootstrap recent-events replay (`createPipeline` → `traces.list({ limit: 30 })`) Both issue `SELECT ... FROM traces ORDER BY ts DESC, id DESC LIMIT n`. Every existing `traces` index leads with `owner_agent_kind`, `owner_profile_id`, `share_scope`, `session_id`, or `episode_id`, so the unfiltered newest-first read degenerates to: ``` EXPLAIN QUERY PLAN SELECT * FROM traces ORDER BY ts DESC, id DESC LIMIT 1; -- SCAN traces -- USE TEMP B-TREE FOR ORDER BY ``` Because better-sqlite3 executes statements synchronously on the JS event loop, the scan blocks all request handling while it runs. ## Fix 1. **`013-traces-ts-index.sql`** — `CREATE INDEX IF NOT EXISTS idx_traces_ts ON traces(ts DESC, id DESC)`. 2. **`migrator.ts`** — same `tableExists("traces")` guard pattern as 012 (partial test schemas must not fail), plus a **release-train collision heal**: databases migrated by the other train carry `schema_migrations` rows whose *version numbers* collide under different *names* (observed live: `(13,'skill-repair-origin')`, `(14,'episode-outcome')`). Version-only bookkeeping silently skipped any same-numbered migration from this build — including additive repair migrations like this one. Guarded/additive migrations now still apply under such collisions and repair their bookkeeping row via upsert; non-guarded migrations keep the conservative skip behaviour. 3. **Regression tests** for both behaviours in `migrator.test.ts`. ## Validation | Check | Before | After | |---|---|---| | EXPLAIN of newest-first read | `SCAN traces` + temp B-tree | `SCAN ... USING INDEX idx_traces_ts` | | Newest-trace lookup (warm) | ~700 ms | ~0.7 ms (>1000×) | | Sandbox boot→serving 200 OK (prod-sized DB copy, previously never served once in 150 s) | never | <6 s | | Production cutover: boot to `pipeline.ready` | 60 s+ stuck | <1 s | | Production health probe | timeout | 200 OK @ ~205 ms | | Restarts/day (watchdog loop) | 321 | 0 since fix | - `tests/unit/storage/`: **82/82 pass** - `tsc --noEmit`: no errors in storage/* (remaining errors are pre-existing `adapters/deepseek-harness/*` optional-dep issues) - Index build cost measured at ~1 s per 100k rows; migration is additive and idempotent ## Test plan - [x] `npx vitest run tests/unit/storage/migrator.test.ts` (8/8) - [x] `npx vitest run tests/unit/storage/` (82/82) - [x] CPU-profiled sandbox reproduction before/after - [x] Live production cutover on a storm-affected install
🤖 Open Code ReviewTarget: PR #2286 🔍 OpenCodeReview found 44 issue(s) in this PR. 1.
|
✅ Automated Test Results: PASSEDAll tests passed (36/36 executed). memos_local_plugin/changed-repo-python: 36/36. Duration: 4s [advisory, non-gating] AI-generated tests on branch test/auto-gen-2bc64e976fa083c0-20260826213933: 17/44 passed, 27 failed — these do NOT affect the PR verdict; review the branch manually. Branch: |
Description
This PR consolidates the local-plugin reliability fixes developed and validated on
fix-local-plugin-260824.Highlights
maxTokens/headersconfiguration through the L3 and Skill Evolver clients;Contribution history is preserved from #2192, #2209, #2246, #2248, #2252, #2253, #2256, and #2284. The capability-driven implementation supersedes the negative probe cache proposed in #2285.
No new runtime dependencies are introduced.
Related Issue (Required): Fixes #2278
Reviewers: @hijzy @whipser030
Type of change
How Has This Been Tested?
npm run test:unit -- --reporter=dot: 179 files passed; 1,549 tests passed and 2 skipped.npm exec vitest run tests/unit/adapters/deepseek-harness-*.test.ts -- --reporter=dot: 65/65 passed.python3 apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py: 36/36 passed.npm run lintnpm run build:packagenpm run check:hermes-versiond76c78aaand installed the identical tarball into DSH0.1.0-rc.6webprofiles on macOS and Windows.61e805f862acbe0873581e22cac4592ab0a9ff4d23d607a5f9fac5df93e80253on both hosts.offprobes across repeated/concurrent calls, supported routes retainoff, adapter invalidation refreshes the capability, and both installed profiles start with healthy Host LLM bridges.Checklist
Reviewer Checklist