Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
213665d
fix(memos-local-plugin): use POSIX-ERE-compatible pgrep pattern for h…
kiwipaulrob Aug 2, 2026
411406d
fix(plugin): archive idle low-eta skills
CovD831 Aug 4, 2026
c5c0d0c
fix(plugin): address idle archive review
CovD831 Aug 4, 2026
a0bd826
Merge remote-tracking branch 'origin/dev-v2.0.29' into codex/preview-…
CovD831 Aug 4, 2026
243b0a6
chore(plugin): align PR with dev-v2.0.29
CovD831 Aug 4, 2026
0ff0a72
fix(plugin): bound idle archive batches
CovD831 Aug 4, 2026
726b23e
Merge remote-tracking branch 'origin/main' into codex/pr-2192-update
Aug 6, 2026
211dd16
fix(plugin): harden Hermes pgrep pattern
Aug 6, 2026
d985d07
refactor(plugin): avoid JS regex captures
Aug 6, 2026
02aa0d8
refactor(plugin): clarify Hermes regex variants
Aug 6, 2026
b2eebd2
Merge remote-tracking branch 'origin/dev-v2.0.29' into codex/pr-2209-…
Aug 6, 2026
11b6a54
chore(plugin): sync idle archive PR with dev-v2.0.29
Aug 6, 2026
0dad4af
fix(plugin): archive idle skills atomically
Aug 6, 2026
10cda28
perf(plugin): batch idle skill archival
Aug 6, 2026
d38dfac
fix(config): resolve masked apiKey from env on load
Aug 13, 2026
1f28c8c
fix(config): apply OCR review to secret env resolver
Aug 13, 2026
70646de
fix(plugin): declare llm.maxTokens and llm.headers as first-class con…
kiwipaulrob Aug 14, 2026
d59fe83
fix(plugin): add l3Llm.maxTokens default (shares SkillEvolverSchema)
kiwipaulrob Aug 14, 2026
1078640
fix(plugin): wire l3Llm/skillEvolver maxTokens+headers; raise dedicat…
kiwipaulrob Aug 14, 2026
5fd4917
fix(core): bound startup recovery wait in shutdown to 15s
kiwipaulrob Aug 15, 2026
2b72698
fix(plugin): default headers {} on l3Llm/skillEvolver slots; maxToken…
kiwipaulrob Aug 15, 2026
763ca1f
fix(skill): auto-generate crystallizer summary/steps instead of rejec…
kiwipaulrob Aug 15, 2026
fa4bb36
fix(hermes-adapter): pass ensure_ascii=False in handle_tool_call json…
Aug 16, 2026
492bc84
test(hermes-adapter): add world_model coverage and drop fragile \u check
Aug 16, 2026
117b2a2
fix(plugin): recover gateway after installer failures
Aug 24, 2026
7b95006
fix(memos-local-plugin): use POSIX-ERE-compatible pgrep pattern for h…
Hun-ger Aug 24, 2026
23e751c
fix(plugin): archive idle low-eta skills (#2209)
Hun-ger Aug 24, 2026
060859d
fix(plugin): run idle skill archival in background
Aug 24, 2026
c6aaf9d
fix(plugin): use OS home for bridge PID fallback
Aug 25, 2026
4d2da63
Fix #2245: bug: masked apiKey written to config.yaml is never resolve…
Hun-ger Aug 25, 2026
e153090
fix(plugin): scope secret environment fallbacks
Aug 25, 2026
ea1aca7
fix(plugin): declare llm.maxTokens and llm.headers as first-class con…
Hun-ger Aug 25, 2026
602f60f
fix(plugin): make free-form config paths explicit
Aug 25, 2026
ecf1092
fix(core): bound startup recovery wait in shutdown to 15s (#2252)
Hun-ger Aug 26, 2026
4a09521
fix(plugin): harden startup recovery shutdown
Aug 26, 2026
d22dbb8
fix(storage): add idx_traces_ts to unblock event loop on newest-first…
kiwipaulrob Aug 26, 2026
7b130ef
fix(skill): auto-generate crystallizer summary/steps instead of rejec…
Hun-ger Aug 26, 2026
7b35fcd
fix(plugin): harden crystallizer draft recovery
Aug 26, 2026
0d9503a
Fix #2255: memos_search returns Chinese text as \uXXXX unicode escape…
Hun-ger Aug 26, 2026
3197691
test(plugin): format Hermes UTF-8 regression
Aug 26, 2026
068a701
fix(plugin): reconcile stale Hermes bridge status
Aug 26, 2026
d6ab77f
docs(storage): document migrator collision-heal allowlist per review
kiwipaulrob Aug 26, 2026
f0f35c7
fix(storage): add idx_traces_ts to unblock event loop on newest-first…
Hun-ger Aug 26, 2026
b90b5c9
fix(plugin): harden newest-trace index rollout
Aug 26, 2026
d76c78a
fix(plugin): resolve auxiliary reasoning capabilities
Aug 26, 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
111 changes: 104 additions & 7 deletions apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type GenerateOptions,
type LlmCallConfig,
type LlmFailure,
type LlmResolvedModelInfo,
type PreparedLlmCall,
type StreamChunk,
} from "@deepseek-ai/dsh-llm";
Expand All @@ -29,6 +30,7 @@ const HOST_LLM_TIMEOUT_CODE = "MEMOS_DSH_HOST_LLM_TIMEOUT";
const HOST_LLM_MESSAGE_SOURCE = "memos-local-memory";
const NO_REASONING_EFFORT = ReasoningEffortId("off");
const UNSUPPORTED_REASONING_EFFORT = "UNSUPPORTED_REASONING_EFFORT";
const MODEL_CAPABILITY_TTL_MS = 10 * 60 * 1_000;

/** Atomic provider/model route captured from the DSH agent that owns a turn. */
export interface DeepSeekHarnessLlmRoute {
Expand All @@ -40,12 +42,34 @@ export interface DeepSeekHarnessLlmRoute {

/** Public subset of DSH's LLM runtime used by this adapter. */
export interface DeepSeekHarnessLlmLike {
resolveModelInfo(
provider: string,
model: string,
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo>;
prepareCall(
config: LlmCallConfig,
signal?: AbortSignal,
): Promise<PreparedLlmCall>;
}

export interface DeepSeekHarnessHostLlmBridge extends HostLlmBridge {
/** Drop exact-route capability snapshots after a DSH adapter topology update. */
invalidateModelCapabilities(): void;
}

type AuxiliaryReasoningCapability = "off" | "plain";

interface CapabilityCacheEntry {
readonly expiresAt: number;
readonly value: Promise<AuxiliaryReasoningCapability>;
}

interface CapabilityCache {
readonly entries: Map<string, CapabilityCacheEntry>;
generation: number;
}

/**
* Async route scope for MemOS work spawned by one DSH session.
*
Expand Down Expand Up @@ -95,9 +119,17 @@ export interface CreateDeepSeekHarnessHostLlmBridgeOptions {
*/
export function createDeepSeekHarnessHostLlmBridge(
options: CreateDeepSeekHarnessHostLlmBridgeOptions,
): HostLlmBridge {
): DeepSeekHarnessHostLlmBridge {
const capabilityCache: CapabilityCache = {
entries: new Map(),
generation: 0,
};
return {
id: HOST_LLM_BRIDGE_ID,
invalidateModelCapabilities(): void {
capabilityCache.generation++;
capabilityCache.entries.clear();
},
async complete(input: HostLlmCompleteInput): Promise<HostLlmCompletion> {
const route = options.routes.current();
if (!route) {
Expand All @@ -122,6 +154,7 @@ export function createDeepSeekHarnessHostLlmBridge(
input,
route,
callDeadline.signal,
capabilityCache,
);
const request = createGenerateOptions(input, route, prepared.config);
request.signal = callDeadline.signal;
Expand Down Expand Up @@ -217,20 +250,30 @@ function createGenerateOptions(
*
* Retrieval filters and JSON extractors intentionally use small output caps.
* Reusing a conversation's high reasoning effort can spend that entire cap on
* reasoning and produce no JSON/text. DSH effort ids are adapter-owned, so the
* exact registered adapter validates the branded conventional `off` id. Only
* an explicit unsupported-effort result retries without it, preserving the
* adapter/provider default. prepareCall performs no provider generation I/O
* and binds that validation to the same registration used for dispatch, even
* if HMR replaces the route before the returned stream starts.
* reasoning and produce no JSON/text. Resolve exact-model metadata through
* DSH, then cache whether its adapter advertises the conventional `off` id.
* prepareCall remains the final registration-bound authority: if HMR changes
* the route after metadata resolution, an explicit unsupported-effort result
* refreshes the cache and retries with the adapter/provider default.
*/
async function prepareAuxiliaryCall(
llm: DeepSeekHarnessLlmLike,
input: HostLlmCompleteInput,
route: DeepSeekHarnessLlmRoute,
signal: AbortSignal,
capabilityCache: CapabilityCache,
): Promise<PreparedLlmCall> {
const config = createCallConfig(input, route);
const capability = await resolveAuxiliaryReasoningCapability(
llm,
route,
signal,
capabilityCache,
);
if (capability === "plain") {
return llm.prepareCall(config, signal);
}
const preparationGeneration = capabilityCache.generation;
try {
return await llm.prepareCall(
{ ...config, reasoningEffort: NO_REASONING_EFFORT },
Expand All @@ -243,10 +286,64 @@ async function prepareAuxiliaryCall(
) {
throw error;
}
// The exact adapter may have changed after the metadata lookup. Preserve
// prepareCall's registration-bound validation as the final authority and
// remember the corrected capability only if no newer topology update has
// already invalidated this preparation generation.
if (capabilityCache.generation === preparationGeneration) {
rememberAuxiliaryReasoningCapability(route, "plain", capabilityCache);
}
return llm.prepareCall(config, signal);
}
}

function capabilityRouteKey(route: DeepSeekHarnessLlmRoute): string {
return JSON.stringify([route.provider, route.model]);
}

function rememberAuxiliaryReasoningCapability(
route: DeepSeekHarnessLlmRoute,
capability: AuxiliaryReasoningCapability,
cache: CapabilityCache,
): void {
cache.entries.set(capabilityRouteKey(route), {
expiresAt: Date.now() + MODEL_CAPABILITY_TTL_MS,
value: Promise.resolve(capability),
});
}

async function resolveAuxiliaryReasoningCapability(
llm: DeepSeekHarnessLlmLike,
route: DeepSeekHarnessLlmRoute,
signal: AbortSignal,
cache: CapabilityCache,
): Promise<AuxiliaryReasoningCapability> {
const key = capabilityRouteKey(route);
const cached = cache.entries.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
if (cached) cache.entries.delete(key);

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

function createCallConfig(
input: HostLlmCompleteInput,
route: DeepSeekHarnessLlmRoute,
Expand Down
17 changes: 17 additions & 0 deletions apps/memos-local-plugin/adapters/deepseek-harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import {
createDeepSeekHarnessHostLlmBridge,
DeepSeekHarnessLlmRouteContext,
type DeepSeekHarnessHostLlmBridge,
} from "./host-llm.js";
import { registerDeepSeekHarnessTools } from "./tools.js";

Expand Down Expand Up @@ -126,6 +127,16 @@ export function configureDeepSeekHarnessHostLlm(
});
}

/** Refresh exact-model capability snapshots whenever DSH replaces an adapter. */
export function registerDeepSeekHarnessHostLlmCapabilityInvalidation(
ctx: Context,
bridge: DeepSeekHarnessHostLlmBridge,
): () => void {
return ctx.on("llm/adapters-updated", () => {
bridge.invalidateModelCapabilities();
});
}

/** Autonomous recovery has no owning DSH turn from which to capture a route. */
export function deepSeekHarnessAutoRecoveryEnabled(config: ResolvedConfig): boolean {
return config.llm.provider.trim().toLowerCase() !== "host" ||
Expand Down Expand Up @@ -247,6 +258,12 @@ export async function apply(
const hostLlmBridge = config.hostLlmEnabled
? createDeepSeekHarnessHostLlmBridge({ llm: ctx.llm, routes })
: null;
if (hostLlmBridge) {
registrations.push(registerDeepSeekHarnessHostLlmCapabilityInvalidation(
ctx,
hostLlmBridge,
));
}
const autoRecoveryEnabled = deepSeekHarnessAutoRecoveryEnabled(memoryConfig);

core = await bootstrapMemoryCore({
Expand Down
38 changes: 23 additions & 15 deletions apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1504,12 +1504,12 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override]

def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) -> str: # type: ignore[override]
if not self._bridge:
return json.dumps({"error": "bridge not connected"})
return json.dumps({"error": "bridge not connected"}, ensure_ascii=False)
try:
if tool_name == "memos_search":
query = (args.get("query") or "").strip()
if not query:
return json.dumps({"error": "missing query"})
return json.dumps({"error": "missing query"}, ensure_ascii=False)
max_results = self._int_arg(args, "maxResults", 10, 1, 50)
params: dict[str, Any] = {
"agent": "hermes",
Expand All @@ -1528,11 +1528,11 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
params,
timeout=_LONG_RPC_TIMEOUT,
)
return json.dumps({"hits": resp.get("hits", [])})
return json.dumps({"hits": resp.get("hits", [])}, ensure_ascii=False)
if tool_name == "memos_get":
item_id = (args.get("id") or "").strip()
if not item_id:
return json.dumps({"error": "missing id"})
return json.dumps({"error": "missing id"}, ensure_ascii=False)
kind = args.get("kind") or "trace"
methods = {
"trace": "memory.get_trace",
Expand All @@ -1541,12 +1541,14 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
}
method = methods.get(kind)
if method is None:
return json.dumps({"error": f"unknown memory kind: {kind}"})
return json.dumps({"error": f"unknown memory kind: {kind}"}, ensure_ascii=False)
item = self._bridge_request_with_retry(
method, {"id": item_id, "namespace": self._runtime_namespace()}
)
if not item:
return json.dumps({"found": False, "kind": kind, "id": item_id})
return json.dumps(
{"found": False, "kind": kind, "id": item_id}, ensure_ascii=False
)
if kind == "trace":
body = self._clip(item.get("agentText") or item.get("body"))
meta = {
Expand Down Expand Up @@ -1584,7 +1586,8 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
"id": item.get("id", item_id),
"body": body,
"meta": meta,
}
},
ensure_ascii=False,
)
if tool_name == "memos_timeline":
resp = self._bridge_request_with_retry(
Expand All @@ -1596,13 +1599,16 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
)
limit = self._int_arg(args, "limit", 20, 1, 100)
traces = resp.get("traces", [])[:limit]
return json.dumps({"traces": traces})
return json.dumps({"traces": traces}, ensure_ascii=False)
if tool_name == "memos_skill_list":
limit = self._int_arg(args, "limit", 10, 1, 50)
params = {"limit": limit, "namespace": self._runtime_namespace()}
if args.get("status"):
params["status"] = args["status"]
return json.dumps(self._bridge_request_with_retry("skill.list", params))
return json.dumps(
self._bridge_request_with_retry("skill.list", params),
ensure_ascii=False,
)
if tool_name == "memos_environment":
query = (args.get("query") or "").strip()
limit = self._int_arg(args, "limit", 5, 1, 30)
Expand All @@ -1621,7 +1627,8 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
for w in resp.get("worldModels", [])
],
"queried": False,
}
},
ensure_ascii=False,
)
resp = self._bridge_request_with_retry(
"memory.search",
Expand Down Expand Up @@ -1651,12 +1658,13 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
for h in hits[:limit]
],
"queried": True,
}
},
ensure_ascii=False,
)
if tool_name == "memos_skill_get":
skill_id = (args.get("id") or "").strip()
if not skill_id:
return json.dumps({"error": "missing id"})
return json.dumps({"error": "missing id"}, ensure_ascii=False)
skill = self._bridge_request_with_retry(
"skill.get",
{
Expand All @@ -1667,10 +1675,10 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
"episodeId": self._episode_id or None,
},
)
return json.dumps({"found": bool(skill), "skill": skill})
return json.dumps({"found": bool(skill), "skill": skill}, ensure_ascii=False)
except Exception as err:
return json.dumps({"error": str(err)})
return json.dumps({"error": f"unknown tool: {tool_name}"})
return json.dumps({"error": str(err)}, ensure_ascii=False)
return json.dumps({"error": f"unknown tool: {tool_name}"}, ensure_ascii=False)

# ─── Config schema (for `hermes memory setup`) ────────────────────────

Expand Down
Loading
Loading