Skip to content
Merged
10 changes: 9 additions & 1 deletion apps/memos-local-plugin/adapters/openclaw/runtime-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ResolvedHome } from "../../core/config/index.js";
const LOCK_DIRNAME = "openclaw-runtime.lock";
const OWNER_FILENAME = "owner.json";
const UNWRITTEN_OWNER_STALE_MS = 30_000;
const heldRuntimeLockDirs = new Set<string>();

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

const owner = readOwner(ownerFile);
if (owner && pidIsAlive(owner.pid)) {
if (heldRuntimeLockDirs.has(lockDir)) {
throw new DuplicateOpenClawRuntimeError(lockDir, owner);
}
// A live self PID without a locally held lock can only be a stale owner
// from an earlier process lifecycle whose PID was recycled by the OS.
if (owner && owner.pid !== pid && pidIsAlive(owner.pid)) {
throw new DuplicateOpenClawRuntimeError(lockDir, owner);
}
if (!owner && !lockLooksStale(lockDir, now(), unwrittenOwnerStaleMs)) {
Expand All @@ -128,11 +134,13 @@ export function acquireOpenClawRuntimeLock(
fs.rmSync(lockDir, { recursive: true, force: true });
throw err;
}
heldRuntimeLockDirs.add(lockDir);

let released = false;
const releaseSync = () => {
if (released) return;
released = true;
heldRuntimeLockDirs.delete(lockDir);
const current = readOwner(ownerFile);
if (current?.token !== owner.token) return;
fs.rmSync(lockDir, { recursive: true, force: true });
Expand Down
46 changes: 42 additions & 4 deletions apps/memos-local-plugin/core/capture/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

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

const warnPartialFailures = (
settled: readonly EmbeddingSettledResult[],
inputCount: number,
): void => {
let failedCount = 0;
for (let i = 0; i < inputCount; i++) {
if (!settled[i]?.ok) failedCount++;
}
if (failedCount > 0) {
const event = failedCount === inputCount ? "embed.failed_all" : "embed.partial_failed";
log.warn(event, { failedCount, inputCount, stepCount: steps.length });
}
};

const summaryTexts = steps.map((s, i) => {
const override = summaryOverrides?.[i]?.trim();
if (override) return override;
Expand All @@ -46,9 +60,19 @@ export async function embedSteps(
const actionTexts = steps.map(actionText);
if (opts.summaryOnly) {
try {
const vecs = await embedder.embedMany(
summaryTexts.map((t) => ({ text: t || "(empty)", role: "document" as const })),
);
const inputs = summaryTexts.map((t) => ({
text: t || "(empty)",
role: "document" as const,
}));
if (embedder.embedManySettled) {
const settled = await embedder.embedManySettled(inputs);
warnPartialFailures(settled, inputs.length);
return steps.map((_, i) => ({
summary: settled[i]?.ok ? settled[i].vector : null,
action: null,
}));
}
const vecs = await embedder.embedMany(inputs);
return steps.map((_, i) => ({ summary: vecs[i] ?? null, action: null }));
} catch (err) {
log.warn("embed.failed_all", { err: errDetail(err), stepCount: steps.length });
Expand All @@ -63,6 +87,20 @@ export async function embedSteps(
];

try {
if (embedder.embedManySettled) {
const settled = await embedder.embedManySettled(inputs);
warnPartialFailures(settled, inputs.length);
const out: VecPair[] = new Array(steps.length);
for (let i = 0; i < steps.length; i++) {
const summary = settled[i];
const action = settled[i + steps.length];
out[i] = {
summary: summary?.ok ? summary.vector : null,
action: action?.ok ? action.vector : null,
};
}
return out;
}
const vecs = await embedder.embedMany(inputs);
const out: VecPair[] = new Array(steps.length);
for (let i = 0; i < steps.length; i++) {
Expand Down
2 changes: 2 additions & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
providerIgnore: [],
providerOrder: [],
openRouter: false,
maxInputTokens: 1_024,
batchSize: 32,
cache: {
enabled: true,
maxItems: 20_000,
Expand Down
18 changes: 18 additions & 0 deletions apps/memos-local-plugin/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export async function loadConfig(home: ResolvedHome, agent?: string): Promise<Lo
}
}

// `maxInputTokens` was introduced with a disabled (`0`) fallback. Keep
// that behaviour for an existing on-disk config that predates the field,
// while config-less/new installations receive the safer 1024 default.
if (fromDisk) raw = withLegacyEmbeddingInputLimit(raw);

const config = resolveConfig(raw, warnings, agent);
return { config, fromDisk, warnings, source: home.configFile };
}
Expand Down Expand Up @@ -177,6 +182,19 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

function withLegacyEmbeddingInputLimit(raw: unknown): unknown {
if (!isPlainObject(raw)) return raw;
const embedding = isPlainObject(raw.embedding) ? raw.embedding : {};
if (Object.hasOwn(embedding, "maxInputTokens")) return raw;
return {
...raw,
embedding: {
...embedding,
maxInputTokens: 0,
},
};
}

function stripUnsupportedEmbeddingDimensions(merged: Record<string, unknown>): void {
const embedding = merged.embedding;
if (!isPlainObject(embedding)) return;
Expand Down
21 changes: 19 additions & 2 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ import { Type, type Static } from "@sinclair/typebox";

const StringWithDefault = (def = "") => Type.String({ default: def });
const Bool = (def: boolean) => Type.Boolean({ default: def });
const NumberInRange = (def: number, min?: number, max?: number) =>
Type.Number({ default: def, ...(min != null ? { minimum: min } : {}), ...(max != null ? { maximum: max } : {}) });
const NumberInRange = (def: number, min?: number, max?: number, description?: string) =>
Type.Number({
default: def,
...(min != null ? { minimum: min } : {}),
...(max != null ? { maximum: max } : {}),
...(description ? { description } : {}),
});

// ─── Sub-schemas ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -45,6 +50,18 @@ const EmbeddingSchema = Type.Object({
providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })),
/** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */
openRouter: Type.Optional(Bool(false)),
/**
* Maximum estimated tokens in one provider input. `0` explicitly disables
* client-side chunking. New installations default to a conservative 1024.
*/
maxInputTokens: NumberInRange(
1_024,
0,
1_000_000,
"Maximum estimated tokens per embedding input. Set to 0 to disable client-side chunking.",
),
/** Maximum physical texts sent in one embedding-provider HTTP request. */
batchSize: NumberInRange(32, 1, 256),
cache: Type.Object({
enabled: Bool(true),
maxItems: NumberInRange(20_000, 0),
Expand Down
13 changes: 13 additions & 0 deletions apps/memos-local-plugin/core/config/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export async function patchConfig(
if (!existingText) {
const initialPort = effectiveViewerPort(agent);
if (initialPort !== undefined) doc.setIn(["viewer", "port"], initialPort);
} else if (
doc.getIn(["embedding", "maxInputTokens"]) === undefined &&
!patchSetsEmbeddingInputLimit(patch)
) {
// Editing an older config must not silently opt it into the new-install
// 1024 default. Persist its prior disabled behaviour before applying the
// unrelated patch so subsequent loads remain stable.
applyPatch(doc, { embedding: { maxInputTokens: 0 } });
}
applyPatch(doc, patch);
if (
Expand Down Expand Up @@ -97,6 +105,11 @@ export async function patchConfig(
return { config, bytes, source: home.configFile, created };
}

function patchSetsEmbeddingInputLimit(patch: Record<string, unknown>): boolean {
const embedding = patch.embedding;
return isPlainObject(embedding) && Object.hasOwn(embedding, "maxInputTokens");
}

/**
* Walk the patch object and apply each leaf to the YAML Document. Deep keys
* are created as needed; arrays are replaced wholesale. Comments on existing
Expand Down
29 changes: 21 additions & 8 deletions apps/memos-local-plugin/core/embedding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ Return type: `Float32Array` of length `config.embedding.dimensions`.
inputs ─▶ normalize(input list → role-tagged {text})
sha256(provider|model|role|text) → cache lookup (LRU)
│ ├── hit ──────────────────────────┐
▼ └── miss → batched by role
batch k texts ──▶ provider.embed()
│ │ │
▼ ▼ ▼
dim-enforce + L2-normalize (Float32Array) ──────▶ interleave in input order
sha256(provider|model|role|full text) → cache lookup (LRU)
│ ├── hit ────────────────────────────
▼ └── miss → token estimate + sampling
physical chunks ──▶ role grouping + provider batchSize
│ │
▼ ▼
dim-enforce + normalize → pool chunks ────────▶ logical input order
└── cache.set(key, vec)
└── cache.set(full-text key, vec)
```

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

`maxInputTokens` (default 1024; set 0 to disable) is an operator-supplied
per-input model limit for providers that do not expose capability metadata
(especially custom `openai_compatible` endpoints). When enabled, the facade uses a conservative
dependency-free token estimate, keeps a safety margin, and represents an
over-limit logical input with at most four uniformly sampled chunks. Chunk
vectors are token-weighted and pooled back to the one-vector public contract.

HTTP 400/413/422 failures on a multi-input provider request trigger bounded
divide-and-conquer splitting. `embedManySettled` exposes the resulting
per-logical-input success/error values so a rejected input does not discard
valid neighbours; authentication, rate-limit, server, and network failures are
not recursively split.

### 2.2 Normalization

Providers return raw float arrays of *their* dimensionality. We enforce the
Expand Down
Loading
Loading