Skip to content

fix(plugin): consolidate local runtime hardening - #2286

Open
Hun-ger wants to merge 45 commits into
mainfrom
fix-local-plugin-260824
Open

fix(plugin): consolidate local runtime hardening#2286
Hun-ger wants to merge 45 commits into
mainfrom
fix-local-plugin-260824

Conversation

@Hun-ger

@Hun-ger Hun-ger commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR consolidates the local-plugin reliability fixes developed and validated on fix-local-plugin-260824.

Highlights

  • hardens Hermes bridge process detection, PID/status reconciliation, installer rollback, Windows-native provider behavior, and UTF-8 tool output;
  • makes secret environment fallback explicit and wires first-class maxTokens / headers configuration through the L3 and Skill Evolver clients;
  • moves idle-skill archival off the foreground path with bounded, atomic lifecycle work;
  • recovers useful crystallizer drafts when LLM output omits derivable fields;
  • bounds startup-recovery shutdown and adds the newest-trace index with collision-safe migration handling;
  • resolves DeepSeek Harness auxiliary reasoning capability from exact model metadata, with per-route TTL caching, concurrent lookup coalescing, adapter-update invalidation, and registration-bound race fallback.

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

  • Bug fix (non-breaking change which fixes an issue)
  • Refactor (does not change functionality, e.g. code style improvements, linting)
  • Documentation update

How Has This Been Tested?

  • Unit Test
    • 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.
  • Test Script Or Test Steps
    • npm run lint
    • npm run build:package
    • npm run check:hermes-version
    • Ruff 0.11.8 check and format verification for both changed Python files.
    • Packed commit d76c78aa and installed the identical tarball into DSH 0.1.0-rc.6 web profiles on macOS and Windows.
    • Verified SHA-256 61e805f862acbe0873581e22cac4592ab0a9ff4d23d607a5f9fac5df93e80253 on both hosts.
    • Verified unsupported-reasoning routes issue zero rejected off probes across repeated/concurrent calls, supported routes retain off, adapter invalidation refreshes the capability, and both installed profiles start with healthy Host LLM bridges.
  • Pipeline Automated API Test
    • GitHub CI will run after PR creation.

Note: make format could not start because Poetry is not installed on the validation host. The equivalent Ruff 0.11.8 checks for the changed Python files passed.

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 (if applicable) | N/A: no external documentation change is required.
  • I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR
  • I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人

Reviewer Checklist

kiwipaulrob and others added 30 commits August 2, 2026 18:04
…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.
Remove #2208 changes after maintainers retargeted #2209 from main to dev-v2.0.29.
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
谁在吵着吃糖 and others added 15 commits August 25, 2026 17:41
…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
@Memtensor-AI Memtensor-AI added area:plugin OpenClaw & Hermes status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Aug 26, 2026
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2286
Task: 2bc64e976fa083c0
Base: main
Head: fix-local-plugin-260824

🔍 OpenCodeReview found 44 issue(s) in this PR.

⚠️ 1 warning(s) occurred during review.


1. apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts (L328-L344)

The .catch callback closes over entry, but entry is assigned after the promise chain is constructed. If resolveModelInfo rejects synchronously (e.g. in unit tests with mock implementations), the .catch fires before entry is written, so cache.entries.get(key) === entry compares against undefined and never removes the stale entry — the rejected promise remains cached indefinitely for this key until TTL expiry.

Fix: assign entry before the async chain, using Promise.resolve().then(...) to guarantee async settlement, or restructure so the entry is written before any .catch can fire:

const entry: CapabilityCacheEntry = {
  expiresAt: Date.now() + MODEL_CAPABILITY_TTL_MS,
  value: Promise.resolve()
    .then(() => llm.resolveModelInfo(route.provider, route.model, signal))
    .then((info): AuxiliaryReasoningCapability => (
      info.reasoning?.efforts.some((e) => e.id === NO_REASONING_EFFORT) ? "off" : "plain"
    ))
    .catch((error: unknown) => {
      if (cache.entries.get(key) === entry) cache.entries.delete(key);
      throw error;
    }),
};
cache.entries.set(key, entry);
return entry.value;

2. apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts (L329-L338)

resolveAuxiliaryReasoningCapability is declared async but returns an un-awaited promise (return value). An async function wraps its return in another Promise, so the actual type is Promise<Promise<AuxiliaryReasoningCapability>> — TypeScript flattens this transparently, but it is misleading style and can confuse future readers or stricter lint rules. Prefer return await value; inside the async function, or drop the async keyword entirely since no await is used in this function body.


3. apps/memos-local-plugin/bridge/status.ts (L177-L183)

The heartbeat timer calls markConnected() unconditionally on every tick, regardless of the current connection state. If a caller invokes markDisconnected(message) without first calling stop() on the heartbeat handle, the next tick (≤5 s) will silently overwrite the 'disconnected' status back to 'connected', masking the outage.

The writer's hermes-process.ts diff only changes the pgrep pattern — it does not show a lifecycle guard that prevents this race. Consider checking the status before writing inside the interval callback.

💡 Suggested Change

Before:

      const timer = setInterval(markConnected, heartbeatMs);
      timer.unref?.();
      return {
        stop() {
          clearInterval(timer);
        },
      };

After:

      const timer = setInterval(() => {
        if (status.status !== "disconnected") markConnected();
      }, heartbeatMs);
      timer.unref?.();
      return {
        stop() {
          clearInterval(timer);
        },
      };

4. apps/memos-local-plugin/bridge/status.ts (L97-L98)

Uses != (loose inequality) instead of !== (strict inequality). The checklist prohibits ==/!=; use !== null here.

💡 Suggested Change

Before:

        status.lastOkAt != null &&
        observedAt - status.lastOkAt <= staleMs;

After:

        status.lastOkAt !== null &&
        observedAt - status.lastOkAt <= staleMs;

5. apps/memos-local-plugin/bridge/status.ts (L106-L107)

Same loose != violation. Additionally, heartbeatStale evaluates to true whenever the last written status was 'connected' with a non-null lastOkAt — but this branch is reached only after freshConnected was already false. The two scenarios ("heartbeat went stale" vs. "process just started with no prior file") produce different heartbeatStale values, which is correct, but naming the variable heartbeatStale when it also captures "was previously connected" makes the intent ambiguous for future readers.

💡 Suggested Change

Before:

      const heartbeatStale =
        status?.status === "connected" && status.lastOkAt != null;

After:

      const hadConnectedHeartbeat =
        status?.status === "connected" && status.lastOkAt !== null;

6. apps/memos-local-plugin/bridge/status.ts (L149-L151)

All I/O errors in writeStatus are silently swallowed. A persistent permission error or full disk will go completely undetected: the writer's in-memory status stays current, but the file on disk is never updated. The Viewer daemon (a separate process) reads only the file, so it will be stuck on the last successful write indefinitely with no indication anything is wrong.

Suggestion: log once on first failure without rethrowing:

let writeErrorLogged = false;
...
} catch (err) {
if (!writeErrorLogged) {
console.error("[bridge/status] Failed to write status file:", err);
writeErrorLogged = true;
}
}


7. apps/memos-local-plugin/bridge/status.ts (L69-L74)

lastOkAt is used as a fallback value for an error timestamp. When no error has ever occurred but a successful heartbeat has, errorAt() returns the time of the last success as if it were the time of the last error. Operators or dashboards reading lastErrorAt from the resulting snapshot will see a misleading timestamp that predates the actual problem onset.

💡 Suggested Change

Before:

function errorAt(
  status: BridgeStatusSnapshot | null,
  observedAt: number,
): number {
  return status?.lastErrorAt ?? status?.lastOkAt ?? observedAt;
}

After:

function errorAt(
  status: BridgeStatusSnapshot | null,
  observedAt: number,
): number {
  return status?.lastErrorAt ?? observedAt;
}

8. apps/memos-local-plugin/bridge/status.ts (L5-L6)

The 4× ratio between heartbeat interval and stale threshold (allowing up to 3 missed heartbeats) is a deliberate safety margin, but it is not documented. If either constant is changed independently in the future, the margin could silently be violated without any warning. A brief comment capturing the invariant would prevent this.

💡 Suggested Change

Before:

export const BRIDGE_STATUS_HEARTBEAT_MS = 5_000;
export const BRIDGE_STATUS_STALE_MS = 20_000;

After:

export const BRIDGE_STATUS_HEARTBEAT_MS = 5_000;
// Stale threshold allows up to 3 missed heartbeats (4× heartbeat interval).
export const BRIDGE_STATUS_STALE_MS = 20_000;

9. apps/memos-local-plugin/core/config/defaults.ts (L62)

The maxTokens default for llm is 1024 while l3Llm and skillEvolver both default to 4096. If intentional (e.g. short retrieval calls vs. synthesis), a brief comment should explain the difference to prevent a future maintainer from silently "fixing" the asymmetry.


10. apps/memos-local-plugin/core/config/defaults.ts (L261)

The arithmetic makes the unit clear but the value 30 encodes a business policy ("archive skills idle for 30 days") with no explanation. Add a short inline comment — e.g. // 30 days — so readers tuning this value understand its intent rather than treating it as an arbitrary constant.


11. apps/memos-local-plugin/core/config/schema.ts (L120-L125)

The maxTokens and headers fields added here are byte-for-byte identical to those added to SkillEvolverSchema below. If this codebase supports composing TypeBox schemas (e.g., a shared LlmBaseSchema partial with Type.Intersect or Type.Composite), extract these two fields to avoid divergence drift — any future constraint change must currently be applied in two places.


12. apps/memos-local-plugin/core/config/schema.ts (L122)

The { default: {} } is passed to Type.Record, but this field is Type.Optional (the key can be entirely absent). In TypeBox/AJV with useDefaults, a nested default only fires when the field is present but the value is missing — not when the field is omitted. If the intent is "default to an empty object when the key is absent", the default must be placed on the outer Type.Optional wrapper or handled in the defaults layer, not inside Type.Record.

💡 Suggested Change

Before:

  headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })),

After:

  headers: Type.Optional(Type.Record(Type.String(), Type.String()), { default: {} }),

13. apps/memos-local-plugin/core/config/schema.ts (L119-L120)

The comment ties the minimum value of 100 to a single model (deepseek-v4-flash). As other providers/models are added this constraint will become misleading without the comment being updated. Model-specific rationale belongs in the PR description or changelog; the schema comment should describe the field generically.


14. apps/memos-local-plugin/bridge/status.ts (L73)

errorAt() falls back to status.lastOkAt when lastErrorAt is null. This reports the timestamp of the last successful heartbeat as if it were the time the error occurred. Any dashboard or log entry reading lastErrorAt will show a timestamp that predates the actual problem onset.

Suggestion: drop the lastOkAt fallback and use observedAt directly:

return status?.lastErrorAt ?? observedAt;


15. apps/memos-local-plugin/core/config/index.ts (L235-L237)

resolveOpenCodeApiKey checks cleaned.llm.provider before deepMerge runs, so a user who omits provider from their YAML (relying on the default "openai_compatible") will have cleaned.llm.provider === undefined, causing the check to return early and silently skip the OpenCode API key fallback. No warning is emitted. resolveSecretEnv — and thus this function — is called on cleaned before deepMerge(DEFAULT_CONFIG, cleaned) is applied, so no default values are present yet.

Fix: move resolveSecretEnv(cleaned, warnings) to run after deepMerge, passing merged instead of cleaned.


16. apps/memos-local-plugin/core/config/index.ts (L331-L337)

FREE_FORM_CONFIG_PATHS.includes(path) checks the parent path passed into the current pruneUnknown call, not the full dotted path of the child key k being processed. When iterating the llm section, path is "llm" and k might be "headers" — but the check tests "llm" against entries like "llm.headers", so it never matches. Conversely, if a parent path were somehow listed, all of its children would incorrectly be passed through as free-form. The check should be made against the child's full dotted path.

Fix:

const childPath = path ? `${path}.${k}` : k;
if (FREE_FORM_CONFIG_PATHS.includes(childPath)) {
  out[k] = v;
  continue;
}
out[k] = pruneUnknown(v, (defaults as Record<string, unknown>)[k], childPath, warnings);

17. apps/memos-local-plugin/core/config/index.ts (L171-L179)

The traversalOk flag is dead code. When the loop breaks due to !isPlainObject(cursor), the break fires before cursor is reassigned, so cursor still holds the non-plain-object value that triggered the guard. The post-loop check !isPlainObject(cursor) is therefore already true in that case, and !traversalOk adds nothing. The comment's concern about cursor pointing at the wrong node is not reachable given the loop structure.

Fix: remove traversalOk and simplify the guard to if (!isPlainObject(cursor)) continue;

💡 Suggested Change

Before:

    let traversalOk = true;
    for (let i = 0; i < keys.length - 1; i++) {
      if (!isPlainObject(cursor)) {
        traversalOk = false;
        break;
      }
      cursor = (cursor as Record<string, unknown>)[keys[i]!];
    }
    if (!traversalOk || !isPlainObject(cursor)) continue;

After:

    for (let i = 0; i < keys.length - 1; i++) {
      if (!isPlainObject(cursor)) break;
      cursor = (cursor as Record<string, unknown>)[keys[i]!];
    }
    if (!isPlainObject(cursor)) continue;

18. apps/memos-local-plugin/core/config/index.ts (L249-L253)

resolveSecretEnv (and this function) runs before Typebox schema validation in resolveConfig. When new URL(endpoint) throws for a malformed endpoint, the catch block is silent — no warning is pushed. The user receives a schema error about the bad endpoint, but no signal that llm.apiKey also wasn't resolved. The two failures are causally linked (bad endpoint → key not resolved → auth fails at runtime), so the missing warning compounds the debugging difficulty.

Fix: push a warning from the catch block indicating that OpenCode key resolution was skipped due to the malformed endpoint value.

💡 Suggested Change

Before:

  } catch {
    // Schema validation reports malformed endpoints later. Secret resolution
    // must not broaden fallback scope just because parsing failed here.
  }
  return undefined;

After:

  } catch {
    warnings?.push(
      `config: 'llm.apiKey' OpenCode fallback skipped — 'llm.endpoint' value is not a valid URL`
    );
  }
  return undefined;

19. apps/memos-local-plugin/core/pipeline/memory-core.ts (L1998-L2004)

Non-timeout errors thrown by startupRecoveryPromise are silently swallowed. The original code had a comment /* already logged inside the recovery promise */, but any error that is not the timeout sentinel (e.g. an unexpected rejection bubbling out of withTimeout) falls through the if block without being logged or re-thrown. This masks real failures at shutdown. Either add an else branch that logs the unexpected error, or re-throw it.

💡 Suggested Change

Before:

      } catch (err) {
        if (
          err instanceof Error &&
          err.message === "startup_recovery_shutdown_timeout"
        ) {
          startupRecoveryTimedOut = true;
          startupRecoveryCancelled = true;

After:

      } catch (err) {
        if (
          err instanceof Error &&
          err.message === "startup_recovery_shutdown_timeout"
        ) {
          startupRecoveryTimedOut = true;
          startupRecoveryCancelled = true;
          log.warn("startup_recovery.shutdown_timeout", {
            timeoutMs: startupRecoveryShutdownGraceMs,
            action: "cancel_and_shutdown_pipeline",
          });
        } else {
          /* already logged inside the recovery promise */
          log.warn("startup_recovery.unexpected_error", {
            err: err instanceof Error ? err.message : String(err),
          });
        }
      }

20. apps/memos-local-plugin/core/pipeline/memory-core.ts (L5076-L5079)

handle.db.tx() return value is not awaited or checked. If tx() is async (or returns a value that signals failure), ignoring it could mean the transaction silently failed while the code below still emits a skill.status.changed event as if the writes succeeded. Verify whether db.tx() is synchronous and throws on failure; if it can return a rejected promise, it must be awaited.

💡 Suggested Change

Before:

    handle.db.tx(() => {
      handle.repos.skills.setStatus(id, "active", now);
      handle.repos.skills.recordUse(id, now);
    });

After:

    await handle.db.tx(() => {
      handle.repos.skills.setStatus(id, "active", now);
      handle.repos.skills.recordUse(id, now);
    });

21. apps/memos-local-plugin/core/pipeline/orchestrator.ts (L1668-L1676)

Double foregroundResources.shutdown() when flushGraceMs === 0. When the caller passes flushGraceMs: 0, (1) foregroundResources.shutdown(reason) fires immediately in the guard block, then (2) settlesWithin(flushPromise, 0) returns false right away (the flush microtask cannot settle in zero real milliseconds), so the if (!completed) branch fires and calls foregroundResources.shutdown(reason) a second time. If shutdown is not idempotent this silently corrupts state or triggers double-teardown side effects.

Suggested fix: skip the inner call when the early-abort path was already taken:

if (flushGraceMs === 0) {
  foregroundResources.shutdown(reason);
}
const flushPromise = flush();
try {
  const completed = await settlesWithin(flushPromise, flushGraceMs);
  if (!completed) {
    log.warn("pipeline.flush_timeout", { reason, timeoutMs: flushGraceMs });
    if (flushGraceMs > 0) foregroundResources.shutdown(reason); // avoid double call
    ...
  }

22. apps/memos-local-plugin/core/pipeline/orchestrator.ts (L1666-L1667)

Magic number defaults should be named constants. The values 15_000 and 4_000 are coupled to Hermes' 20 s shutdown ceiling (noted in the adjacent comment). Inlining them makes silent drift easy — changing one without the other can blow past the outer ceiling. Extract to module-level constants:

const DEFAULT_FLUSH_GRACE_MS = 15_000;
const DEFAULT_ABORT_WAIT_MS  = 4_000;

23. apps/memos-local-plugin/core/skill/lifecycle-worker.ts (L68-L74)

runNow() and flush() both expose the raw promise from beginRun(), so rejections propagate to callers with no log output. trigger() wraps the same promise in a .catch() that logs the error, but callers of runNow()/flush() will silently swallow failures unless they handle the rejection themselves. Either document that callers are responsible, or add a consistent .catch() log path here.


24. apps/memos-local-plugin/core/skill/lifecycle-worker.ts (L48-L49)

When a lifecycle run is still active (running is truthy) and the interval fires, trigger() silently skips that tick with no retry scheduled. A run lasting longer than intervalMs will effectively double the wait until the next cycle. This behaviour should at minimum be documented with a comment; if it matters for correctness, consider scheduling a deferred retry when the active run finishes.


25. apps/memos-local-plugin/core/skill/lifecycle-worker.ts (L41-L43)

The .then()/.finally() chain here is intentional — beginRun() must return the same Promise object it assigns to running so the identity check (running === current) in finally works correctly. A short comment explaining this invariant would prevent future readers from refactoring it to async/await and silently breaking the deduplication logic.


26. apps/memos-local-plugin/core/skill/lifecycle-worker.ts (L63)

The double cast is the only TypeScript-safe way to call Node.js's unref() without importing @types/node. A one-line comment (e.g. // Node.js: prevent this timer from keeping the process alive) would clarify intent for reviewers unfamiliar with the pattern.


27. apps/memos-local-plugin/core/skill/crystallize.ts (L741-L751)

defaultDraftValidator silently mutates its argument. A function named "validator" with a void return type is expected to be a pure predicate — callers and tests can observe unexpected changes to draft.summary. The repair logic already lives in prepareDraft's repairedFields path, so this is also duplicated. Remove the mutation here and rely solely on the prepareDraft repair path; restore the original throw new Error("skill.crystallize.invalid: missing summary") behaviour.

💡 Suggested Change

Before:

  if (!draft.summary) {
    // Auto-generate a summary from the richest available field. Use `||` not
    // `??`: LLM JSON emits empty strings, and `??` only falls through on
    // null/undefined.
    const autoSummary =
      draft.steps?.[0]?.body ||
      draft.steps?.[0]?.title ||
      draft.displayTitle ||
      draft.name;
    draft.summary = autoSummary.slice(0, 200);
  }

After:

  if (!draft.summary) {
    throw new Error("skill.crystallize.invalid: missing summary");
  }

28. apps/memos-local-plugin/core/skill/crystallize.ts (L424-L425)

Identity-equality guard validate !== defaultDraftValidator is fragile. If a caller wraps defaultDraftValidator in an adapter (e.g. (d) => defaultDraftValidator(d)), both run, which currently double-mutates draft.summary and could double-throw in edge cases. Consider removing the deduplication guard and documenting that callers should not re-pass defaultDraftValidator as the custom validator.

💡 Suggested Change

Before:

    defaultDraftValidator(draft);
    if (validate && validate !== defaultDraftValidator) validate(draft);

After:

    defaultDraftValidator(draft);
    if (validate) validate(draft);

29. apps/memos-local-plugin/core/skill/crystallize.ts (L489-L494)

stepUsesAlias produces false positives: a step object that has both canonical fields (title, body) and extra alias fields (name, description, etc.) is flagged even though no alias normalisation was needed. The "step-aliases" log entry would fire spuriously. Add a check that the canonical fields are absent before declaring an alias was used.

💡 Suggested Change

Before:

function stepUsesAlias(value: unknown): boolean {
  if (typeof value === "string") return true;
  if (!isRecord(value)) return false;
  return value.name !== undefined || value.description !== undefined ||
    value.content !== undefined || value.instruction !== undefined;
}

After:

function stepUsesAlias(value: unknown): boolean {
  if (typeof value === "string") return true;
  if (!isRecord(value)) return false;
  const hasCanonical = cleanOptionalText(value.title) || cleanOptionalMarkdown(value.body);
  if (hasCanonical) return false;
  return value.name !== undefined || value.description !== undefined ||
    value.content !== undefined || value.instruction !== undefined;
}

30. apps/memos-local-plugin/core/skill/crystallize.ts (L167-L168)

malformedRetries: 0 is a silent behaviour change if this field previously defaulted to a non-zero value. It is unclear whether this is intentional (retries are now handled by crystallizeDraft's own catch block) or an accidental disabling of retries. Add a brief inline comment explaining the intent.

💡 Suggested Change

Before:

        schemaHint: SKILL_DRAFT_SCHEMA_HINT,
        malformedRetries: 0,

After:

        schemaHint: SKILL_DRAFT_SCHEMA_HINT,
        malformedRetries: 0, // retries handled by crystallizeDraft's own catch block

31. apps/memos-local-plugin/core/skill/subscriber.ts (L243)

archiveNextIdleBatch() is called bare with no try/catch. If the repository layer throws (e.g., SQLITE_BUSY or a schema mismatch), the entire lifecycleTick() rejects, potentially silencing the error or crashing the subscriber. Wrap the call in a try/catch that logs and breaks out of the loop gracefully.

💡 Suggested Change

Before:

      const archivedSkills = deps.repos.skills.archiveNextIdleBatch({

After:

      let archivedSkills: ReturnType<typeof deps.repos.skills.archiveNextIdleBatch>;
      try {
        archivedSkills = deps.repos.skills.archiveNextIdleBatch({

32. apps/memos-local-plugin/core/skill/subscriber.ts (L278-L286)

The if/else conflates two unrelated concerns: 'should I warn?' and 'should I yield?'. On the final iteration the else branch is skipped because the while-condition immediately becomes false — correct intent, but hard to reason about. Separate the concerns: unconditionally yield between productive batches, and warn once after the loop if the limit was reached.

💡 Suggested Change

Before:

      if (batchesProcessed === IDLE_ARCHIVE_MAX_BATCHES_PER_TICK) {
        log.warn("skill.idle_archive_batch_limit_reached", {
          batchCount: batchesProcessed,
          archivedCount: archivedTotal,
          batchSize: IDLE_ARCHIVE_BATCH_LIMIT,
        });
      } else {
        await yieldToEventLoop();
      }

After:

      await yieldToEventLoop();
    }
    if (batchesProcessed >= IDLE_ARCHIVE_MAX_BATCHES_PER_TICK && archivedTotal > 0) {
      log.warn("skill.idle_archive_batch_limit_reached", {
        batchCount: batchesProcessed,
        archivedCount: archivedTotal,
        batchSize: IDLE_ARCHIVE_BATCH_LIMIT,
      });

33. apps/memos-local-plugin/core/skill/subscriber.ts (L40)

The JSDoc hardcodes 'ten' in prose, so it silently goes stale if the constant value changes. Either drop the comment (the name IDLE_ARCHIVE_MAX_BATCHES_PER_TICK is self-documenting) or make it value-agnostic and explain the why.

💡 Suggested Change

Before:

/** Bound one lifecycle pass to ten repository-sized archival batches. */

After:

/** Cap archival batches per lifecycle tick to avoid starving the event loop. */

34. apps/memos-local-plugin/core/storage/migrations/018-traces-ts-index.sql (L26)

No ANALYZE after index creation. SQLite's query planner uses statistics from sqlite_stat1; on an existing large traces table (the described ~30k row / ~235 MB case) these stats may be absent or stale. Without fresh statistics the planner may not choose the new index on the very first query after upgrade — exactly the boot-time latestTimestamp() call inside runMigrationsensureHubSharingSearchColumns path. Adding ANALYZE traces; immediately after the CREATE INDEX statement ensures the planner sees accurate cardinality data from the start.


35. apps/memos-local-plugin/core/storage/repos/traces.ts (L109-L111)

The first generic argument types the bound parameters. Since this query has no parameters, unknown is inaccurate — it signals "parameter type is unknown" rather than "no parameters". The repo's own style uses concrete types (e.g. { id: string } in the adjacent selectById). Prefer [] or Record<string, never> to match better-sqlite3 convention for zero-param statements.

💡 Suggested Change

Before:

  const selectLatestTimestamp = db.prepare<unknown, { ts: number }>(
    `SELECT ts FROM traces ORDER BY ts DESC, id DESC LIMIT 1`,
  );

After:

  const selectLatestTimestamp = db.prepare<[], { ts: number }>(
    `SELECT ts FROM traces ORDER BY ts DESC, id DESC LIMIT 1`,
  );

36. apps/memos-local-plugin/tests/unit/skill/_helpers.ts (L43)

This magic number duplicates the production default from core/config/defaults.ts (also modified in this PR). If the canonical default changes, this test helper silently stays at 30 days and tests may no longer exercise the real default. Import the constant from defaults.ts instead of repeating the literal.

💡 Suggested Change

Before:

    idleArchiveMs: 30 * 24 * 60 * 60 * 1000,

After:

    idleArchiveMs: DEFAULT_SKILL_CONFIG.idleArchiveMs,

37. apps/memos-local-plugin/install.ps1 (L67-L72)

The throw here produces a raw PowerShell ErrorRecord with a full stack trace when unhandled, burying the human-readable message. The pattern is also repeated in Install-OpenClaw where Stop-Die was replaced with a bare throw. Consider emitting a coloured Write-Host before throwing, or a small helper (Throw-WithMessage) so the user sees a clear error without a stack dump.


38. apps/memos-local-plugin/install.ps1 (L510-L515)

State keys are bare string literals ("inactive", "needs_recovery", "final_failed"). PS 5.1 has no enum type; a single-character typo in any assignment or in the finally comparison silently evaluates the wrong branch. The final_failed branch is load-bearing — a typo there causes a spurious second gateway start on failure. Define constants at the top of the function and reference only those:

$GW_INACTIVE       = 'inactive'
$GW_NEEDS_RECOVERY = 'needs_recovery'
$GW_FINAL_FAILED   = 'final_failed'

39. apps/memos-local-plugin/install.ps1 (L666-L673)

The inner catch correctly prevents a secondary recovery exception from masking the original install error. However, if the recovery block grows (e.g. a log flush is added after this try/catch), an exception from new code would escape the finally and silently replace the original error. Document the invariant in a comment, or wrap the entire finally body in an outer try/catch:

} finally {
    try {
        if ($GatewayRecoveryState -eq "needs_recovery") { … }
    } catch {
        Write-Warn "Unexpected error in recovery cleanup: $($_.Exception.Message)"
    }
}

40. apps/memos-local-plugin/install.ps1 (L522-L524)

The original Stop-Die presumably printed a formatted, coloured message before exiting. The bare throw here triggers the finally-block recovery correctly, but the user-facing error is now buried in a PS ErrorRecord stack trace. Emit a Write-Host/Write-Error before throwing so the message remains visible regardless of how the caller handles the exception.


41. apps/memos-local-plugin/install.ps1 (L684-L692)

The two critical recovery paths — (a) deploy fails after gateway stop → recovery start called exactly once, and (b) final gateway start fails → recovery does NOT start again (final_failed) — have no automated test coverage. A regression in either path leaves the user with a silently stopped gateway. Add Pester tests mocking Invoke-OpenClawGatewayChecked and Deploy-Tarball covering the happy path, the deploy-failure recovery path, and the final-start-failure path.


42. apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py (L1072-L1074)

If SHARED_BRIDGE_REGISTRY.close_all() raises, self._mode_patch.stop() is never reached and the MEMOS_HERMES_BRIDGE_MODE env-var patch leaks into every subsequent test in the process.

Fix: register the stop via addCleanup in setUp immediately after start()addCleanup is guaranteed to run even when tearDown raises.

def setUp(self) -> None:
    memos_provider.SHARED_BRIDGE_REGISTRY.close_all()
    self._mode_patch = patch.dict("os.environ", {"MEMOS_HERMES_BRIDGE_MODE": "legacy"})
    self._mode_patch.start()
    self.addCleanup(self._mode_patch.stop)  # guaranteed even if tearDown raises

def tearDown(self) -> None:
    memos_provider.SHARED_BRIDGE_REGISTRY.close_all()
    # _mode_patch.stop() handled by addCleanup above

43. apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py (L1137-L1139)

The trace and world_model variants of this test both guard with self.assertTrue(parsed["found"]) before probing nested fields. The policy variant skips that guard and jumps straight to parsed["body"].

If the provider returns {"found": False, ...} for the policy kind (e.g. a routing bug silently returns the not-found path), parsed["body"] will either be absent (raising KeyError) or empty — but the raw-string assertIn on _CH_POLICY_TITLE may still pass if the title leaked into an error message. This makes the policy branch a weaker regression guard than the other two.

Add self.assertTrue(parsed["found"]) before the assertIn on parsed["body"].

💡 Suggested Change

Before:

        self.assertIn(ChineseToolResultBridge._CH_POLICY_TITLE, raw)
        parsed = json.loads(raw)
        self.assertIn(ChineseToolResultBridge._CH_POLICY_BODY, parsed["body"])

After:

        self.assertIn(ChineseToolResultBridge._CH_POLICY_TITLE, raw)
        parsed = json.loads(raw)
        self.assertTrue(parsed["found"])
        self.assertIn(ChineseToolResultBridge._CH_POLICY_BODY, parsed["body"])

44. apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py (L1205-L1207)

The list variant checks parsed structure (parsed["worldModels"][0]["title"]), proving encoding end-to-end. The query variant only checks the raw string with assertIn and then parsed["queried"]. If the world-model titles inside the hits were serialized with ensure_ascii=True while another field still contained the Chinese literal verbatim, the raw-string check would pass while the actual regression went undetected.

Add a structural assertion on the parsed hits. The bridge's memory.search response for this path returns snippet containing _CH_WORLD_TITLE, so:

self.assertTrue(parsed["queried"])
self.assertIn(
    ChineseToolResultBridge._CH_WORLD_TITLE,
    parsed["hits"][0]["snippet"],
)
💡 Suggested Change

Before:

        self.assertIn(ChineseToolResultBridge._CH_WORLD_TITLE, raw)
        parsed = json.loads(raw)
        self.assertTrue(parsed["queried"])

After:

        self.assertIn(ChineseToolResultBridge._CH_WORLD_TITLE, raw)
        parsed = json.loads(raw)
        self.assertTrue(parsed["queried"])
        self.assertIn(
            ChineseToolResultBridge._CH_WORLD_TITLE,
            parsed["hits"][0]["snippet"],
        )

🧹 Filtered 10 low-confidence OCR finding(s) before posting/fix-loop (existing_code_mismatch: 3, duplicate: 7).

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

✅ Automated Test Results: PASSED

All 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: fix-local-plugin-260824

@Memtensor-AI Memtensor-AI added status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发 and removed status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:plugin OpenClaw & Hermes status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] memos-local-plugin: --daemon entry never starts the status heartbeat — health reports "stale" forever while RPC works

5 participants