Skip to content

Commit 9119efe

Browse files
fix(plugin): harden local plugin runtime and embeddings (#2266)
## Description This PR consolidates the tested local-plugin fixes from `fix-20260820-local-plugin`: - preserves the OpenClaw stale/recycled PID lock fix from #2099; - makes packed OpenClaw and Hermes installations compatible with npm 11 when development peer dependencies are omitted; - replaces the character-based head truncation proposed in #2123 with provider-level `batchSize`, token-based `maxInputTokens`, bounded four-chunk sampling and vector pooling, adaptive batch splitting for 400/413/422 responses, and per-input failure isolation across capture, import, and embedding rebuild paths; - defaults new installations to `maxInputTokens: 1024`, while preserving `0` for existing configurations created before this setting; - removes the separate rebuild “items per request” control and exposes only provider batch size and the per-input token limit. This supersedes #2123. Related Issue (Required): Fixes #2121 Related: #2099 ## Type of change - [x] 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) - [x] Documentation update ## How Has This Been Tested? - [x] Unit Test - `pnpm test`: 176 test files passed; 1,466 tests passed and 2 skipped. - Targeted config, embedding, install, runtime-lock, and pipeline suite: 258 tests passed and 1 skipped. - [x] Test Script Or Test Steps - `pnpm lint` - `pnpm build:package` - macOS: installed and behavior-tested with OpenClaw and Hermes. - Windows: installed and behavior-tested with OpenClaw, Hermes, and DeepSeek Harness. - [ ] Pipeline Automated API Test (not applicable) `make format` was also attempted, but Poetry is not installed in the local environment (`poetry: No such file or directory`). The plugin's own lint, full tests, and production build all pass. ## 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] I have created related documentation issue/PR in [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) (if applicable) | Not applicable; the in-repository configuration templates and embedding documentation were updated. - [x] I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用) - [x] I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人 @Memtensor-AI @syzsunshine219 please review. ## Reviewer Checklist - [ ] closes #2121 (Replace xxxx with the GitHub issue number) - [ ] Made sure Checks passed - [ ] Tests have been provided
2 parents be68e2f + c6231a5 commit 9119efe

28 files changed

Lines changed: 1024 additions & 197 deletions

‎apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ResolvedHome } from "../../core/config/index.js";
77
const LOCK_DIRNAME = "openclaw-runtime.lock";
88
const OWNER_FILENAME = "owner.json";
99
const UNWRITTEN_OWNER_STALE_MS = 30_000;
10+
const heldRuntimeLockDirs = new Set<string>();
1011

1112
export interface OpenClawRuntimeLockOwner {
1213
pluginId: string;
@@ -101,7 +102,12 @@ export function acquireOpenClawRuntimeLock(
101102
if (e.code !== "EEXIST") throw err;
102103

103104
const owner = readOwner(ownerFile);
104-
if (owner && pidIsAlive(owner.pid)) {
105+
if (heldRuntimeLockDirs.has(lockDir)) {
106+
throw new DuplicateOpenClawRuntimeError(lockDir, owner);
107+
}
108+
// A live self PID without a locally held lock can only be a stale owner
109+
// from an earlier process lifecycle whose PID was recycled by the OS.
110+
if (owner && owner.pid !== pid && pidIsAlive(owner.pid)) {
105111
throw new DuplicateOpenClawRuntimeError(lockDir, owner);
106112
}
107113
if (!owner && !lockLooksStale(lockDir, now(), unwrittenOwnerStaleMs)) {
@@ -128,11 +134,13 @@ export function acquireOpenClawRuntimeLock(
128134
fs.rmSync(lockDir, { recursive: true, force: true });
129135
throw err;
130136
}
137+
heldRuntimeLockDirs.add(lockDir);
131138

132139
let released = false;
133140
const releaseSync = () => {
134141
if (released) return;
135142
released = true;
143+
heldRuntimeLockDirs.delete(lockDir);
136144
const current = readOwner(ownerFile);
137145
if (current?.token !== owner.token) return;
138146
fs.rmSync(lockDir, { recursive: true, force: true });

‎apps/memos-local-plugin/core/capture/embedder.ts‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import { MemosError } from "../../agent-contract/errors.js";
14-
import type { Embedder } from "../embedding/index.js";
14+
import type { Embedder, EmbeddingSettledResult } from "../embedding/index.js";
1515
import { rootLogger } from "../logger/index.js";
1616
import type { EmbeddingVector } from "../types.js";
1717
import type { NormalizedStep } from "./types.js";
@@ -38,6 +38,20 @@ export async function embedSteps(
3838
const log = rootLogger.child({ channel: "core.capture.embed" });
3939
if (steps.length === 0) return [];
4040

41+
const warnPartialFailures = (
42+
settled: readonly EmbeddingSettledResult[],
43+
inputCount: number,
44+
): void => {
45+
let failedCount = 0;
46+
for (let i = 0; i < inputCount; i++) {
47+
if (!settled[i]?.ok) failedCount++;
48+
}
49+
if (failedCount > 0) {
50+
const event = failedCount === inputCount ? "embed.failed_all" : "embed.partial_failed";
51+
log.warn(event, { failedCount, inputCount, stepCount: steps.length });
52+
}
53+
};
54+
4155
const summaryTexts = steps.map((s, i) => {
4256
const override = summaryOverrides?.[i]?.trim();
4357
if (override) return override;
@@ -46,9 +60,19 @@ export async function embedSteps(
4660
const actionTexts = steps.map(actionText);
4761
if (opts.summaryOnly) {
4862
try {
49-
const vecs = await embedder.embedMany(
50-
summaryTexts.map((t) => ({ text: t || "(empty)", role: "document" as const })),
51-
);
63+
const inputs = summaryTexts.map((t) => ({
64+
text: t || "(empty)",
65+
role: "document" as const,
66+
}));
67+
if (embedder.embedManySettled) {
68+
const settled = await embedder.embedManySettled(inputs);
69+
warnPartialFailures(settled, inputs.length);
70+
return steps.map((_, i) => ({
71+
summary: settled[i]?.ok ? settled[i].vector : null,
72+
action: null,
73+
}));
74+
}
75+
const vecs = await embedder.embedMany(inputs);
5276
return steps.map((_, i) => ({ summary: vecs[i] ?? null, action: null }));
5377
} catch (err) {
5478
log.warn("embed.failed_all", { err: errDetail(err), stepCount: steps.length });
@@ -63,6 +87,20 @@ export async function embedSteps(
6387
];
6488

6589
try {
90+
if (embedder.embedManySettled) {
91+
const settled = await embedder.embedManySettled(inputs);
92+
warnPartialFailures(settled, inputs.length);
93+
const out: VecPair[] = new Array(steps.length);
94+
for (let i = 0; i < steps.length; i++) {
95+
const summary = settled[i];
96+
const action = settled[i + steps.length];
97+
out[i] = {
98+
summary: summary?.ok ? summary.vector : null,
99+
action: action?.ok ? action.vector : null,
100+
};
101+
}
102+
return out;
103+
}
66104
const vecs = await embedder.embedMany(inputs);
67105
const out: VecPair[] = new Array(steps.length);
68106
for (let i = 0; i < steps.length; i++) {

‎apps/memos-local-plugin/core/config/defaults.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
4040
providerIgnore: [],
4141
providerOrder: [],
4242
openRouter: false,
43+
maxInputTokens: 1_024,
44+
batchSize: 32,
4345
cache: {
4446
enabled: true,
4547
maxItems: 20_000,

‎apps/memos-local-plugin/core/config/index.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ export async function loadConfig(home: ResolvedHome, agent?: string): Promise<Lo
6565
}
6666
}
6767

68+
// `maxInputTokens` was introduced with a disabled (`0`) fallback. Keep
69+
// that behaviour for an existing on-disk config that predates the field,
70+
// while config-less/new installations receive the safer 1024 default.
71+
if (fromDisk) raw = withLegacyEmbeddingInputLimit(raw);
72+
6873
const config = resolveConfig(raw, warnings, agent);
6974
return { config, fromDisk, warnings, source: home.configFile };
7075
}
@@ -177,6 +182,19 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
177182
return typeof v === "object" && v !== null && !Array.isArray(v);
178183
}
179184

185+
function withLegacyEmbeddingInputLimit(raw: unknown): unknown {
186+
if (!isPlainObject(raw)) return raw;
187+
const embedding = isPlainObject(raw.embedding) ? raw.embedding : {};
188+
if (Object.hasOwn(embedding, "maxInputTokens")) return raw;
189+
return {
190+
...raw,
191+
embedding: {
192+
...embedding,
193+
maxInputTokens: 0,
194+
},
195+
};
196+
}
197+
180198
function stripUnsupportedEmbeddingDimensions(merged: Record<string, unknown>): void {
181199
const embedding = merged.embedding;
182200
if (!isPlainObject(embedding)) return;

‎apps/memos-local-plugin/core/config/schema.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@ import { Type, type Static } from "@sinclair/typebox";
1414

1515
const StringWithDefault = (def = "") => Type.String({ default: def });
1616
const Bool = (def: boolean) => Type.Boolean({ default: def });
17-
const NumberInRange = (def: number, min?: number, max?: number) =>
18-
Type.Number({ default: def, ...(min != null ? { minimum: min } : {}), ...(max != null ? { maximum: max } : {}) });
17+
const NumberInRange = (def: number, min?: number, max?: number, description?: string) =>
18+
Type.Number({
19+
default: def,
20+
...(min != null ? { minimum: min } : {}),
21+
...(max != null ? { maximum: max } : {}),
22+
...(description ? { description } : {}),
23+
});
1924

2025
// ─── Sub-schemas ────────────────────────────────────────────────────────────
2126

@@ -45,6 +50,18 @@ const EmbeddingSchema = Type.Object({
4550
providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })),
4651
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
4752
openRouter: Type.Optional(Bool(false)),
53+
/**
54+
* Maximum estimated tokens in one provider input. `0` explicitly disables
55+
* client-side chunking. New installations default to a conservative 1024.
56+
*/
57+
maxInputTokens: NumberInRange(
58+
1_024,
59+
0,
60+
1_000_000,
61+
"Maximum estimated tokens per embedding input. Set to 0 to disable client-side chunking.",
62+
),
63+
/** Maximum physical texts sent in one embedding-provider HTTP request. */
64+
batchSize: NumberInRange(32, 1, 256),
4865
cache: Type.Object({
4966
enabled: Bool(true),
5067
maxItems: NumberInRange(20_000, 0),

‎apps/memos-local-plugin/core/config/writer.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ export async function patchConfig(
6161
if (!existingText) {
6262
const initialPort = effectiveViewerPort(agent);
6363
if (initialPort !== undefined) doc.setIn(["viewer", "port"], initialPort);
64+
} else if (
65+
doc.getIn(["embedding", "maxInputTokens"]) === undefined &&
66+
!patchSetsEmbeddingInputLimit(patch)
67+
) {
68+
// Editing an older config must not silently opt it into the new-install
69+
// 1024 default. Persist its prior disabled behaviour before applying the
70+
// unrelated patch so subsequent loads remain stable.
71+
applyPatch(doc, { embedding: { maxInputTokens: 0 } });
6472
}
6573
applyPatch(doc, patch);
6674
if (
@@ -97,6 +105,11 @@ export async function patchConfig(
97105
return { config, bytes, source: home.configFile, created };
98106
}
99107

108+
function patchSetsEmbeddingInputLimit(patch: Record<string, unknown>): boolean {
109+
const embedding = patch.embedding;
110+
return isPlainObject(embedding) && Object.hasOwn(embedding, "maxInputTokens");
111+
}
112+
100113
/**
101114
* Walk the patch object and apply each leaf to the YAML Document. Deep keys
102115
* are created as needed; arrays are replaced wholesale. Comments on existing

‎apps/memos-local-plugin/core/embedding/README.md‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ Return type: `Float32Array` of length `config.embedding.dimensions`.
4747
inputs ─▶ normalize(input list → role-tagged {text})
4848
│
4949
▼
50-
sha256(provider|model|role|text) → cache lookup (LRU)
51-
│ ├── hit ──────────────────────────┐
52-
▼ └── miss → batched by role │
53-
batch k texts ──▶ provider.embed() │
54-
│ │ │
55-
▼ ▼ ▼
56-
dim-enforce + L2-normalize (Float32Array) ──────▶ interleave in input order
50+
sha256(provider|model|role|full text) → cache lookup (LRU)
51+
│ ├── hit ────────────────────────────┐
52+
▼ └── miss → token estimate + sampling │
53+
physical chunks ──▶ role grouping + provider batchSize │
54+
│ │ │
55+
▼ ▼ ▼
56+
dim-enforce + normalize → pool chunks ────────▶ logical input order
5757
│
58-
└── cache.set(key, vec)
58+
└── cache.set(full-text key, vec)
5959
```
6060

6161
The cache is indexed by `sha256(provider|model|role|text)` in hex. Duplicate
@@ -69,6 +69,19 @@ are grouped by `role` first, then chunked into batches. That way a mixed
6969
list (some `query`, some `document`) still yields two role-correct round trips
7070
instead of one role-ambiguous one.
7171

72+
`maxInputTokens` (default 1024; set 0 to disable) is an operator-supplied
73+
per-input model limit for providers that do not expose capability metadata
74+
(especially custom `openai_compatible` endpoints). When enabled, the facade uses a conservative
75+
dependency-free token estimate, keeps a safety margin, and represents an
76+
over-limit logical input with at most four uniformly sampled chunks. Chunk
77+
vectors are token-weighted and pooled back to the one-vector public contract.
78+
79+
HTTP 400/413/422 failures on a multi-input provider request trigger bounded
80+
divide-and-conquer splitting. `embedManySettled` exposes the resulting
81+
per-logical-input success/error values so a rejected input does not discard
82+
valid neighbours; authentication, rate-limit, server, and network failures are
83+
not recursively split.
84+
7285
### 2.2 Normalization
7386

7487
Providers return raw float arrays of *their* dimensionality. We enforce the

0 commit comments

Comments
 (0)