Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5b342f8
feat(api): align OpenMem v1 SDK with cloud API
Zyntrael Jul 8, 2026
57f0919
Merge branch 'MemTensor:main' into dev-v2.0.23-sdk
Zyntrael Jul 8, 2026
36f6849
Merge branch 'MemTensor:main' into dev-v2.0.23-sdk
Zyntrael Jul 14, 2026
f46c02e
feat(api): align OpenMem v1 SDK with cloud API
Zyntrael Jul 14, 2026
39643a2
Merge remote-tracking branch '我的/dev-v2.0.23-sdk' into dev-v2.0.23-sdk
Zyntrael Jul 14, 2026
c9745f6
align OpenMem v1 SDK with cloud API
bittergreen Jul 14, 2026
9d2c9b7
fix(memos-local): include json hint in user messages (#1756)
de1tydev Jul 14, 2026
a000a1c
docs: fix scheduler API examples to match server endpoints (#2113)
shinetata Jul 16, 2026
2c60267
fix(api): show structured add example in /docs for /product/add (#2112)
shinetata Jul 16, 2026
c0f6c2c
fix(memos-local-plugin): revert half-merged MemosHttpClient usages (#…
Memtensor-AI Jul 16, 2026
698c30f
fix: configure logging once per process
bittergreen Jul 16, 2026
2017cd5
fix: close scheduler resources on API shutdown
bittergreen Jul 16, 2026
8aefda7
fix: configure logging once per process
bittergreen Jul 16, 2026
9c7dd2f
fix: close scheduler resources via API lifespan
bittergreen Jul 16, 2026
61c1322
fix: stabilize logging and RabbitMQ shutdown lifecycle (#2117)
bittergreen Jul 16, 2026
e072676
feat(api):update SDK version number
Zyntrael Jul 17, 2026
e8b6690
feat(api):update SDK version number (#2118)
bittergreen Jul 17, 2026
b740411
fix(memos-local-plugin): guard oversized embedding inputs and isolate…
Jul 19, 2026
868b877
fix(memos-local-plugin): address OCR review findings for #2121 fix
Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 12 additions & 54 deletions apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,11 @@
if str(_PLUGIN_DIR) not in sys.path:
sys.path.insert(0, str(_PLUGIN_DIR))

from bridge_client import BridgeError, MemosBridgeClient, MemosHttpClient # noqa: E402
from bridge_client import BridgeError, MemosBridgeClient # noqa: E402
from daemon_manager import ( # noqa: E402
ensure_bridge_running,
ensure_viewer_daemon,
kill_zombie_bridges,
probe_viewer_status,
startup_lock_active,
)


Expand Down Expand Up @@ -279,7 +277,7 @@ class MemTensorProvider(MemoryProvider):
"""

def __init__(self) -> None:
self._bridge: MemosBridgeClient | MemosHttpClient | None = None
self._bridge: MemosBridgeClient | None = None
self._reconnect_lock = threading.Lock()
self._session_id: str = ""
self._episode_id: str = ""
Expand Down Expand Up @@ -329,23 +327,6 @@ def is_available(self) -> bool: # type: ignore[override]

# ─── Lifecycle ────────────────────────────────────────────────────────

def _connect_http_bridge(self, session_id: str, *, timeout: float = 60.0) -> bool:
"""Try to connect via HTTP bridge. Sets self._bridge on success."""
http_bridge: MemosHttpClient | None = None
try:
http_bridge = MemosHttpClient()
http_bridge.register_host_handler("host.llm.complete", self._handle_host_llm_complete)
self._bridge = http_bridge
self._open_session(session_id, timeout=timeout)
return True
except Exception as err:
logger.warning("MemOS: HTTP bridge failed, falling back to stdio — %s", err)
if http_bridge is not None:
with contextlib.suppress(Exception):
http_bridge.close()
self._bridge = None
return False

def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[override]
"""Called once at agent startup.

Expand Down Expand Up @@ -414,32 +395,13 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[ov
except Exception:
pass

# If the daemon is already running on the viewer port, connect
# to it over HTTP instead of spawning a new stdio bridge. This
# eliminates zombie bridge accumulation.
viewer_status = probe_viewer_status()
if viewer_status == "running_memos":
if self._connect_http_bridge(session_id):
logger.info(
"MemOS: bridge ready (HTTP) session=%s platform=%s (episode deferred)",
self._session_id,
self._platform,
)
else:
viewer_status = "free" # force stdio fallback below
elif viewer_status == "free":
# Re-probe after a short wait only when another process may be
# mid-startup (startup lock is held). On a cold first-launch the
# lock doesn't exist, so we skip the delay entirely.
if startup_lock_active():
time.sleep(1.0)
viewer_status = probe_viewer_status()
if viewer_status == "running_memos" and self._connect_http_bridge(session_id):
logger.info(
"MemOS: bridge ready (HTTP, late probe) session=%s platform=%s (episode deferred)",
self._session_id,
self._platform,
)
# NOTE: An HTTP bridge path used to live here that connected to a
# running viewer daemon over HTTP instead of spawning a stdio
# subprocess. It depended on ``MemosHttpClient`` which was never
# committed — the class was referenced by name only. Issue #2096
# reverts the half-merged HTTP feature; the stdio path below is
# the sole connection mechanism until the HTTP client lands as a
# complete change.

if self._bridge is None:
try:
Expand Down Expand Up @@ -1958,13 +1920,9 @@ def _reconnect_bridge(self, session_id: str = "", *, timeout: float = 30.0) -> N
logger.info("MemOS: old bridge closed (pid=%s)", old_pid)

ensure_bridge_running()
# Try HTTP first if daemon is running
viewer_status = probe_viewer_status()
if viewer_status == "running_memos" and self._connect_http_bridge(
session_id, timeout=timeout
):
logger.info("MemOS: reconnected via HTTP")
return
# NOTE: HTTP bridge reconnect path was removed alongside issue
# #2096. See ``initialize`` for the rationale. Reconnect always
# spawns a fresh stdio bridge.

try:
ensure_viewer_daemon()
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 @@ -4,6 +4,7 @@
* to change.
*/

import { DEFAULT_MAX_INPUT_CHARS } from "../embedding/constants.js";
import type { ResolvedConfig } from "./schema.js";

export const DEFAULT_CONFIG: ResolvedConfig = {
Expand Down Expand Up @@ -31,6 +32,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: true,
maxItems: 20_000,
},
maxInputChars: DEFAULT_MAX_INPUT_CHARS,
},
llm: {
provider: "",
Expand Down
14 changes: 14 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import { Type, type Static } from "@sinclair/typebox";

import { DEFAULT_MAX_INPUT_CHARS } from "../embedding/constants.js";

// ─── Reusable building blocks ───────────────────────────────────────────────

const StringWithDefault = (def = "") => Type.String({ default: def });
Expand Down Expand Up @@ -43,6 +45,18 @@ const EmbeddingSchema = Type.Object({
enabled: Bool(true),
maxItems: NumberInRange(20_000, 0),
}, { default: {} }),
/**
* Per-input character cap applied inside the `Embedder` facade before
* hashing / calling the provider. Guards against remote embedding
* models with a per-request token limit — most notably 智谱
* `embedding-3` (3072-token single-input cap; CJK averages ~1.3–1.5
* chars per token, so the 4000-char default keeps CJK-dominant text
* under the limit), which returns HTTP 400 `code:1210` for
* over-length inputs and used to nuke the whole rebuild batch
* (issue #2121). Set to `0` to disable truncation. The default is
* `DEFAULT_MAX_INPUT_CHARS` in `core/embedding/constants.ts`.
*/
maxInputChars: NumberInRange(DEFAULT_MAX_INPUT_CHARS, 0),
}, { default: {} });

const LlmSchema = Type.Object({
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/embedding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ Unit tests live in `tests/unit/embedding/`:
- `gemini`'s `?key=<API_KEY>` puts the secret in the URL; `fetcher.ts`
redacts query string via the logger's redaction pipeline. Do not log the
raw URL elsewhere.
- `fetcher.ts` attaches a truncated (≤ 512 chars) provider response body to
`http.non_ok` warn logs so operators can see error codes like 智谱's
`code:1210` (issue #2121). This field is a **verbatim third-party
payload**: some providers echo fragments of the submitted embedding input
(i.e. potentially personal memory content) back inside error responses.
Treat any log sink that carries `http.non_ok` (e.g. `gateway.log`) as
potentially containing user data — apply the same retention/access rules
as for raw memory content.
- `voyage` and `cohere` charge per token; be mindful when bumping
`batchSize` — large batches amortize HTTP overhead but hit TPM ceilings.
- Changing `dimensions` in config after writing vectors to SQLite breaks
Expand Down
29 changes: 29 additions & 0 deletions apps/memos-local-plugin/core/embedding/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Shared embedding constants. Kept dependency-free so `core/config/`
* (schema + defaults) can import from here without dragging in the
* provider implementations behind `embedder.ts`.
*/

/**
* Default per-input character cap for embedding inputs.
*
* Chosen at 4000 chars with 智谱 embedding-3's 3072-token single-input
* hard limit as the reference worst case: GLM tokenizers average
* ~1.3–1.5 chars per token for Chinese, so 4000 CJK chars ≈ 2700–3000
* tokens — under the cap for typical content (the previous 6000
* default mapped to ≈ 4000–4600 tokens and could still trip HTTP 400
* `code:1210` on CJK-dominant inputs). ASCII tokenizes at ~4
* chars/token, so 4000 chars ≈ 1000 tokens, safe for every supported
* provider. The cap is a guard, not a hard guarantee — pathological
* inputs that still overflow are isolated per-slot by the
* divide-and-conquer retry in `pipeline/memory-core.ts`.
*
* Callers can override via `EmbeddingConfig.maxInputChars`; `0`, a
* negative value, or `Infinity` disables truncation (see
* `resolveMaxInputChars` in `embedder.ts`). See issue #2121.
*
* This constant is the single source of truth — `config/schema.ts` and
* `config/defaults.ts` import it so the schema default, the runtime
* default, and the facade fallback can never drift apart.
*/
export const DEFAULT_MAX_INPUT_CHARS = 4000;
45 changes: 44 additions & 1 deletion apps/memos-local-plugin/core/embedding/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
makeCacheKey,
type EmbedCache,
} from "./cache.js";
import { DEFAULT_MAX_INPUT_CHARS } from "./constants.js";
import { postProcess } from "./normalize.js";
import { CohereEmbeddingProvider } from "./providers/cohere.js";
import { GeminiEmbeddingProvider } from "./providers/gemini.js";
Expand Down Expand Up @@ -107,7 +108,29 @@ export function createEmbedderWithProvider(
requests += inputs.length;
if (inputs.length === 0) return [];

const normalized = inputs.map(toInput);
// Per-input character cap. Truncation runs BEFORE cache-key hashing
// so a repeated call with the same head text hits the LRU. Guards
// against provider single-input token caps (e.g. 智谱 embedding-3
// rejects >3072 tokens with HTTP 400 code:1210 — see issue #2121).
const cap = resolveMaxInputChars(config.maxInputChars);
let truncatedCount = 0;
const normalized = inputs.map(toInput).map((inp) => {
if (cap > 0 && inp.text.length > cap) {
truncatedCount++;
return { ...inp, text: inp.text.slice(0, cap) };
}
return inp;
});
if (truncatedCount > 0) {
logger.warn("input_truncated", {
provider: provider.name,
model: config.model,
cap,
count: truncatedCount,
of: normalized.length,
});
}

const results = new Array<EmbeddingVector | null>(normalized.length).fill(null);
const dedupEnabled = config.cache.enabled;
const keys = normalized.map((inp, i) => {
Expand Down Expand Up @@ -332,6 +355,26 @@ export function createEmbedderWithProvider(

// ─── Provider lookup ─────────────────────────────────────────────────────────

/**
* Resolve the effective per-input character cap from config.
*
* Semantics (mirrors the `EmbeddingConfig.maxInputChars` JSDoc):
* - `undefined` → `DEFAULT_MAX_INPUT_CHARS` (guard on by default)
* - `NaN` → `DEFAULT_MAX_INPUT_CHARS` (invalid value — e.g.
* a typo'd config — must NOT silently disable the
* guard, or issue #2121 sneaks back in)
* - `0` / negative → `0` (documented explicit opt-out)
* - `Infinity` → `0` ("no cap" — explicit opt-out)
* - any other number → `Math.floor(value)`
*/
export function resolveMaxInputChars(configured: number | undefined): number {
if (configured === undefined || Number.isNaN(configured)) {
return DEFAULT_MAX_INPUT_CHARS;
}
if (configured < 0 || !Number.isFinite(configured)) return 0;
return Math.floor(configured);
}

export function makeProviderFor(name: EmbeddingProviderName): EmbeddingProvider {
switch (name) {
case "local":
Expand Down
12 changes: 12 additions & 0 deletions apps/memos-local-plugin/core/embedding/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,24 @@ export async function httpPostJson<TResp>(opts: HttpPostOpts<unknown>): Promise<
if (!resp.ok) {
const text = await safeText(resp);
const transient = resp.status >= 500 || resp.status === 429;
// Include a truncated body so operators can pinpoint provider
// error codes (e.g. 智谱 `code:1210` for over-length inputs)
// directly from gateway.log without needing a debugger — see
// issue #2121.
//
// SENSITIVITY: `body` is a verbatim excerpt of a third-party
// response. Providers sometimes echo fragments of the submitted
// input (which may contain personal memory content) back inside
// error payloads, so operators must treat log sinks carrying
// this field with the same care as raw memory content — see
// "Caveats" in core/embedding/README.md.
opts.log.warn("http.non_ok", {
url: opts.url,
status: resp.status,
attempt,
transient,
durationMs: Date.now() - start,
body: text ? text.slice(0, 512) : undefined,
});
if (transient && attempt <= maxRetries) {
await backoff(attempt);
Expand Down
2 changes: 2 additions & 0 deletions apps/memos-local-plugin/core/embedding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ export {
createEmbedder,
createEmbedderWithProvider,
makeProviderFor,
resolveMaxInputChars,
} from "./embedder.js";
export { DEFAULT_MAX_INPUT_CHARS } from "./constants.js";
export {
LruEmbedCache,
NullEmbedCache,
Expand Down
15 changes: 15 additions & 0 deletions apps/memos-local-plugin/core/embedding/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ export interface EmbeddingConfig {
maxRetries?: number;
/** Max texts per HTTP round trip. Default: 32. */
batchSize?: number;
/**
* Per-input character cap. Inputs longer than this are truncated
* (character-wise, not token-wise) before being hashed / sent to the
* provider. Guards against provider single-input token caps such as
* 智谱 embedding-3 (3072 tokens; CJK ≈ 1.3–1.5 chars/token). Set `0`,
* a negative value, or `Infinity` to disable; `NaN` (invalid config)
* falls back to the default rather than disabling the guard.
* Default: `DEFAULT_MAX_INPUT_CHARS` (4000) from `constants.ts`.
*
* Truncation happens at the facade boundary so all providers (local,
* openai_compatible, gemini, cohere, voyage, mistral) benefit; the
* cache key is derived from the *truncated* text so repeat calls that
* share the same head text hit the LRU.
*/
maxInputChars?: number;
/** Extra headers to tack on outgoing HTTP. */
headers?: Record<string, string>;
/** If true, all output vectors are L2-normalized. Default: true. */
Expand Down
19 changes: 17 additions & 2 deletions apps/memos-local-plugin/core/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ export function createLlmClientWithProvider(
return [{ role: "system", content: systemInsert }, ...messages];
}

function ensureJsonWordInUserMessage(messages: LlmMessage[]): LlmMessage[] {
const lastUserIdx = messages.map((m) => m.role).lastIndexOf("user");
if (lastUserIdx < 0) return [...messages, { role: "user", content: "Return valid json only." }];

const msg = messages[lastUserIdx];
if (/\bjson\b/i.test(msg.content)) return messages;

const out = messages.slice();
out[lastUserIdx] = {
...msg,
content: `${msg.content}\n\nReturn valid json only.`,
};
return out;
}

function buildCallInput(opts: LlmCallOptions | undefined, jsonMode: boolean): ProviderCallInput {
return {
temperature: opts?.temperature ?? config.temperature,
Expand Down Expand Up @@ -463,7 +478,7 @@ export function createLlmClientWithProvider(
): Promise<LlmCompletion> {
const messages = normalizeMessages(input);
const msgsWithJsonHint = opts?.jsonMode
? inject(messages, buildJsonSystemHint())
? ensureJsonWordInUserMessage(inject(messages, buildJsonSystemHint()))
: messages;
const call = buildCallInput(opts, opts?.jsonMode === true);
const { completion } = await callWithFallback(msgsWithJsonHint, call, opts, opts?.op ?? "complete");
Expand All @@ -476,7 +491,7 @@ export function createLlmClientWithProvider(
): Promise<LlmJsonCompletion<T>> {
const messages = normalizeMessages(input);
const systemHint = buildJsonSystemHint(opts.schemaHint);
const msgs = inject(messages, systemHint);
const msgs = ensureJsonWordInUserMessage(inject(messages, systemHint));
const call = buildCallInput(opts, true);
const op = opts.op ?? "complete.json";
const maxMalformedRetries = Math.max(0, opts.malformedRetries ?? 1);
Expand Down
Loading
Loading