From 8bfa10a0f5990c8cd5674efed70be735ae8642ab Mon Sep 17 00:00:00 2001 From: Memtensor-AI Date: Tue, 21 Jul 2026 16:23:32 +0800 Subject: [PATCH 01/33] ci: automate OpenClaw local plugin release notes (#2129) Generate evidence-backed OpenClaw local plugin release notes through the configured draft service. Add dry-run and publish workflow safeguards, retry/repair validation, prerelease handling, failure reporting hooks, redacted inspection artifacts, and focused local plugin test coverage. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../draft-local-plugin-release-notes.mjs | 1353 +++++++++++++++++ .../draft-local-plugin-release-notes.test.mjs | 653 ++++++++ .github/scripts/retry.sh | 67 + .../memos-local-plugin-post-merge-dry-run.yml | 29 + .../workflows/memos-local-plugin-publish.yml | 446 +++++- .github/workflows/python-release.yml | 1 + .../tests/e2e/v7-full-chain.e2e.test.ts | 7 + .../tests/unit/startup-recovery.test.ts | 13 +- .../tests/unit/storage/migrator.test.ts | 16 +- 9 files changed, 2537 insertions(+), 48 deletions(-) create mode 100644 .github/scripts/draft-local-plugin-release-notes.mjs create mode 100644 .github/scripts/draft-local-plugin-release-notes.test.mjs create mode 100644 .github/scripts/retry.sh create mode 100644 .github/workflows/memos-local-plugin-post-merge-dry-run.yml diff --git a/.github/scripts/draft-local-plugin-release-notes.mjs b/.github/scripts/draft-local-plugin-release-notes.mjs new file mode 100644 index 000000000..b4a593659 --- /dev/null +++ b/.github/scripts/draft-local-plugin-release-notes.mjs @@ -0,0 +1,1353 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; + +const PRODUCT_PATH = "apps/memos-local-plugin"; +const PRODUCT_ID = "openclaw-local-plugin"; +const PRODUCT_TITLE = { + zh: "OpenClaw 本地插件", + en: "OpenClaw Local Plugin", +}; +export const RELEASE_NOTE_GUIDANCE = { + category_policy: { + Added: + "Use for newly exposed user-facing capabilities, configuration entries, model slots, language support, or workflow modes.", + Improved: + "Use for performance optimization, context formatting, compatibility hardening, startup/link/install robustness, or reliability improvements when the evidence is not primarily a broken-behavior fix.", + Fixed: + "Use for concrete broken behavior, regressions, data recovery bugs, failed retries, endpoint probing anomalies, or configuration that existed but did not take effect.", + }, + quality_policy: [ + "Prefer Added / Improved / Fixed sections when the evidence supports all three; omit a section only when evidence is insufficient.", + "Do not collapse a new configuration capability and a bug fix in the same subsystem into one Fixed item; keep the added capability and the fixed behavior separate when both are evidenced.", + "Map vector scan CPU reductions, XML memory-context boundaries, and Hermes provider startup/link/install compatibility to Improved unless the evidence clearly describes a user-visible regression.", + "Keep bullets short, product-facing, and evidence-backed; do not mention internal file names unless they are the feature name users recognize.", + ], + translation_policy: [ + "Treat text_cn as the canonical release-note wording first, then translate text_cn into text_en.", + "Do not independently invent English facts beyond the Chinese canonical bullet and its source_refs.", + "Keep category and source_refs identical between Chinese and English outputs.", + "text_cn must contain Chinese text; text_en must not contain Chinese/CJK characters.", + ], +}; +const CURRENT_TAG_PREFIX = "memos-local-plugin-v"; +const TAG_PREFIXES = [CURRENT_TAG_PREFIX, "openclaw-local-plugin-v"]; +const RELEASE_NOTES_MARKER = "doc-agent-release-notes-json"; +const RELEASE_CATEGORY_ORDER = ["Added", "Improved", "Fixed"]; +const MAX_DRAFT_REPAIR_ATTEMPTS = 2; +const RELEASE_TO_DOC_CATEGORY = { + Added: "New Features", + Improved: "Improvements", + Fixed: "Bug Fixes", +}; +const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/; + +function fail(message) { + throw new Error(String(message)); +} + +function warn(message) { + console.error(`::warning::${message}`); +} + +function sh(args, options = {}) { + return execFileSync("git", args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }).trim(); +} + +export function cleanVersion(raw) { + const value = String(raw || "").trim(); + return value.startsWith("v") ? value.slice(1) : value; +} + +export function displayVersion(raw) { + const value = cleanVersion(raw); + return value ? `v${value}` : ""; +} + +export function versionFromTag(tag) { + for (const prefix of TAG_PREFIXES) { + if (tag.startsWith(prefix)) { + return cleanVersion(tag.slice(prefix.length)); + } + } + return ""; +} + +export function parseSemver(version) { + const cleaned = cleanVersion(version); + const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+]([0-9A-Za-z.-]+))?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] || "", + }; +} + +export function compareSemver(a, b) { + const av = parseSemver(a); + const bv = parseSemver(b); + if (!av || !bv) return String(a).localeCompare(String(b)); + for (const key of ["major", "minor", "patch"]) { + if (av[key] !== bv[key]) return av[key] - bv[key]; + } + if (av.prerelease === bv.prerelease) return 0; + if (!av.prerelease) return 1; + if (!bv.prerelease) return -1; + return av.prerelease.localeCompare(bv.prerelease); +} + +function gitShowJson(ref, path) { + try { + return JSON.parse(sh(["show", `${ref}:${path}`])); + } catch { + return {}; + } +} + +function readJsonFile(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return {}; + } +} + +function tagInfo(ref) { + const text = sh(["show", "--no-patch", "--format=%H%n%ci%n%s", ref]); + const [sha = "", date = "", subject = ""] = text.split("\n"); + return { tag: ref, sha, date, subject }; +} + +function refInfo(ref, tagLabel) { + const info = tagInfo(ref); + return { ...info, tag: tagLabel || ref, ref }; +} + +function refExists(ref) { + try { + sh(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +export function resolveCurrentRef( + currentTag, + { requestedRef = process.env.RELEASE_EVIDENCE_REF || "", refExistsImpl = refExists } = {}, +) { + const explicit = String(requestedRef || "").trim(); + if (explicit) { + if (!refExistsImpl(explicit)) { + fail(`RELEASE_EVIDENCE_REF does not exist or is not a commit: ${explicit}`); + } + return explicit; + } + if (currentTag && refExistsImpl(currentTag)) return currentTag; + return "HEAD"; +} + +function listProductTags() { + try { + sh(["fetch", "--tags", "--force", "origin"], { stdio: ["ignore", "ignore", "ignore"] }); + } catch { + warn("Failed to fetch tags from origin; using locally available tags."); + } + + const text = sh(["tag", "--list"]); + return text + .split("\n") + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => ({ tag, version: versionFromTag(tag) })) + .filter((item) => item.version && parseSemver(item.version)); +} + +export function findPreviousTag(targetVersion, currentTag) { + const candidates = listProductTags() + .filter((item) => item.tag !== currentTag) + .filter((item) => compareSemver(item.version, targetVersion) < 0) + .sort((a, b) => { + const versionOrder = compareSemver(b.version, a.version); + if (versionOrder !== 0) return versionOrder; + const aPreferred = a.tag.startsWith(CURRENT_TAG_PREFIX) ? 1 : 0; + const bPreferred = b.tag.startsWith(CURRENT_TAG_PREFIX) ? 1 : 0; + return bPreferred - aPreferred; + }); + return candidates[0]?.tag || ""; +} + +function parseCommits(previousTag, currentRef) { + const text = sh([ + "log", + "--format=%H%x09%h%x09%s", + "--no-merges", + `${previousTag}..${currentRef}`, + "--", + PRODUCT_PATH, + ]); + return text + .split("\n") + .filter(Boolean) + .map((line) => { + const [sha = "", shortSha = "", subject = ""] = line.split("\t"); + return { sha, short_sha: shortSha, subject }; + }); +} + +function parseChangedFiles(previousTag, currentRef) { + const text = sh(["diff", "--name-status", `${previousTag}..${currentRef}`, "--", PRODUCT_PATH]); + return text + .split("\n") + .filter(Boolean) + .map((line) => { + const parts = line.split("\t"); + const item = { status: parts[0], path: parts[parts.length - 1] }; + if (parts.length === 3) item.old_path = parts[1]; + return item; + }); +} + +function packageChanges(previousTag, currentRef) { + const path = `${PRODUCT_PATH}/package.json`; + const before = gitShowJson(previousTag, path); + const after = currentRef === "HEAD" ? readJsonFile(path) : gitShowJson(currentRef, path); + return ["name", "version"] + .filter((field) => before[field] !== after[field]) + .map((field) => ({ field, before: before[field], after: after[field] })); +} + +function extractPullRequests(commits, repo = process.env.GITHUB_REPOSITORY || "MemTensor/MemOS") { + const seen = new Set(); + for (const commit of commits) { + for (const match of String(commit.subject || "").matchAll(/#(\d+)/g)) { + seen.add(match[1]); + } + } + return [...seen].map((number) => ({ + number, + url: `https://github.com/${repo}/pull/${number}`, + })); +} + +function refsForGuidance(commit) { + const refs = []; + if (commit.short_sha) refs.push(commit.short_sha); + for (const match of String(commit.subject || "").matchAll(/#(\d+)/g)) { + const value = `#${match[1]}`; + if (!refs.includes(value)) refs.push(value); + } + return refs; +} + +function categoryHintForSubject(subject) { + const value = String(subject || ""); + const lower = value.toLowerCase(); + if ( + lower.startsWith("release:") || + /^fix(\([^)]+\))?:\s*ruff\b/i.test(value) || + /review feedback/i.test(value) + ) { + return null; + } + if (/^(feat|feature|add)(\([^)]+\))?:|^add\s+/i.test(value)) { + return { + category: "Added", + reason: "new user-facing capability or configuration support", + }; + } + if ( + /cpu|full-table vector scan|vector scan|xml boundary|memory context with xml|hermes|provider link|bridge package version|startup poll|double-spawn/i.test( + value, + ) + ) { + return { + category: "Improved", + reason: "performance, context-boundary, or integration robustness improvement", + }; + } + if (/^(perf|performance|refactor)(\([^)]+\))?:/i.test(value)) { + return { + category: "Improved", + reason: "performance or implementation improvement", + }; + } + if ( + /lightweightmemory|skip evolution|endpoint paths|openai_compatible|empty response|error response|dirty-reward|orphan trace|session exemption|loopback/i.test( + lower, + ) + ) { + return { + category: "Fixed", + reason: "specific broken behavior, recovery boundary, retry, auth, or endpoint probing fix", + }; + } + if (/^(fix|hotfix|bugfix)(\([^)]+\))?:|^fix\s+#\d+/i.test(value)) { + return { + category: "Fixed", + reason: "specific bug fix", + }; + } + return null; +} + +export function categoryHintsForCommits(commits) { + return commits + .map((commit) => { + const hint = categoryHintForSubject(commit.subject); + const sourceRefs = refsForGuidance(commit); + if (!hint || sourceRefs.length === 0) return null; + return { + ...hint, + source_refs: sourceRefs, + subject: commit.subject, + }; + }) + .filter(Boolean); +} + +export function releaseNoteGuidanceForCommits(commits) { + return { + ...RELEASE_NOTE_GUIDANCE, + source_ref_category_hints: categoryHintsForCommits(commits), + source_ref_hint_policy: + "Treat source_ref_category_hints as advisory classification hints grounded in evidence. " + + "Use them to avoid moving performance/compatibility work into Fixed merely because the commit subject starts with fix.", + }; +} + +export function collectEvidence({ targetVersion, currentTag, previousTag, currentRef = "HEAD" }) { + const commits = parseCommits(previousTag, currentRef); + const changedFiles = parseChangedFiles(previousTag, currentRef); + const diffRange = `${previousTag}..${currentRef}`; + const repo = process.env.GITHUB_REPOSITORY || "MemTensor/MemOS"; + const importantDiff = sh([ + "diff", + "--unified=2", + diffRange, + "--", + PRODUCT_PATH, + ]).slice(0, 24000); + + return { + product_id: PRODUCT_ID, + product_title: PRODUCT_TITLE, + release_note_guidance: releaseNoteGuidanceForCommits(commits), + repo, + previous_tag: previousTag, + current_tag: currentTag, + current_ref: currentRef, + diff_range: diffRange, + target_version: displayVersion(targetVersion), + git_ref: sh(["rev-parse", "--short=12", currentRef]), + previous: tagInfo(previousTag), + current: refInfo(currentRef, currentTag), + commits, + pull_requests: extractPullRequests(commits, repo), + changed_files: changedFiles, + diff_stat: sh(["diff", "--stat", diffRange, "--", PRODUCT_PATH]), + important_diff: { + [`${PRODUCT_PATH}/**`]: importantDiff, + }, + package_changes: packageChanges(previousTag, currentRef), + test_changes: changedFiles.filter((item) => item.path.includes("/tests/")), + docs_changes: changedFiles.filter((item) => item.path.includes("/docs/")), + }; +} + +export function evidenceForInspection(evidence) { + const guidance = evidence?.release_note_guidance || {}; + const { + release_note_guidance: _releaseNoteGuidance, + important_diff: _importantDiff, + ...publicEvidence + } = evidence || {}; + return { + ...publicEvidence, + release_note_guidance: { + source_ref_category_hints: Array.isArray(guidance.source_ref_category_hints) + ? guidance.source_ref_category_hints + : [], + }, + redactions: { + important_diff: "omitted from public workflow artifacts; sent only to the configured draft service", + release_note_prompt_guidance: "omitted from public workflow artifacts", + }, + }; +} + +export function draftForInspection(draft) { + return { + ok: Boolean(draft?.ok), + needs_review: Boolean(draft?.needs_review), + confidence: draft?.confidence || "", + release_items: Array.isArray(draft?.release_items) ? draft.release_items : [], + coverage: { + needs_review: Boolean(draft?.coverage?.needs_review), + required_count: Number(draft?.coverage?.required_count || 0), + covered_required_count: Number(draft?.coverage?.covered_required_count || 0), + missing_required_count: Number(draft?.coverage?.missing_required_count || 0), + covered_refs: Array.isArray(draft?.coverage?.covered_refs) ? draft.coverage.covered_refs : [], + missing_required: Array.isArray(draft?.coverage?.missing_required) ? draft.coverage.missing_required : [], + invalid_item_refs: Array.isArray(draft?.coverage?.invalid_item_refs) ? draft.coverage.invalid_item_refs : [], + }, + warnings: Array.isArray(draft?.warnings) ? draft.warnings : [], + language_issues: Array.isArray(draft?.language_issues) ? draft.language_issues : [], + postprocess: draft?.postprocess || {}, + validation_report: draft?.validation_report || {}, + validation_attempt_count: Number(draft?.validation_attempt_count || 0), + repair_attempt_count: Number(draft?.repair_attempt_count || 0), + repair_attempts: Array.isArray(draft?.repair_attempts) ? draft.repair_attempts : [], + redactions: { + server_debug_fields: "omitted from public workflow artifacts", + model_and_prompt_details: "omitted from public workflow artifacts", + }, + }; +} + +function appendOutput(name, value) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) return; + writeFileSync(outputPath, `${name}<<__DOC_AGENT_EOF__\n${value}\n__DOC_AGENT_EOF__\n`, { + flag: "a", + }); +} + +export function ensureSourceHint(notes) { + const hint = ``; + return notes.includes("doc-agent: source-id=") ? notes : `${notes.trim()}\n\n${hint}\n`; +} + +function normalizeReleaseCategory(value) { + const text = String(value || "").trim(); + return RELEASE_CATEGORY_ORDER.includes(text) ? text : ""; +} + +function normalizeSourceRef(value) { + const text = String(value || "").trim().replace(/^[`[(\s]+|[`)\],.;\s]+$/g, ""); + if (/^\d{2,}$/.test(text)) return `#${text}`; + if (/^#\d+$/.test(text)) return text; + if (/^[a-fA-F0-9]{7,40}$/.test(text)) return text.toLowerCase(); + return ""; +} + +function normalizeSourceRefs(raw) { + const values = Array.isArray(raw) ? raw : String(raw || "").match(/#\d+|[a-fA-F0-9]{7,40}/g) || []; + const refs = []; + for (const value of values) { + const ref = normalizeSourceRef(value); + if (ref && !refs.includes(ref)) refs.push(ref); + } + return refs; +} + +function refsForCommit(commit) { + const refs = []; + for (const ref of [commit?.short_sha, commit?.sha]) { + const normalized = normalizeSourceRef(ref); + if (normalized && !refs.includes(normalized)) refs.push(normalized); + } + for (const match of String(commit?.subject || "").matchAll(/#(\d+)/g)) { + const ref = `#${match[1]}`; + if (!refs.includes(ref)) refs.push(ref); + } + return refs; +} + +function normalizeReleaseItem(raw) { + if (!raw || typeof raw !== "object") return null; + const category = normalizeReleaseCategory(raw.category); + const textCn = String(raw.text_cn || "").trim().replace(/^-+\s*/, ""); + const textEn = String(raw.text_en || "").trim().replace(/^-+\s*/, ""); + const sourceRefs = normalizeSourceRefs(raw.source_refs); + if (!category || !textCn || !textEn || sourceRefs.length === 0) return null; + return { + category, + text_cn: textCn, + text_en: textEn, + source_refs: sourceRefs, + }; +} + +function buildSourceRefIndex(evidence) { + const refToGroup = new Map(); + const groups = new Map(); + const knownRefs = new Set(); + + for (const commit of evidence?.commits || []) { + for (const ref of refsForCommit(commit)) knownRefs.add(ref); + } + + for (const hint of evidence?.release_note_guidance?.source_ref_category_hints || []) { + const refs = normalizeSourceRefs(hint.source_refs); + const category = normalizeReleaseCategory(hint.category); + if (refs.length === 0 || !category) continue; + const groupKey = refs[0]; + for (const ref of refs) { + knownRefs.add(ref); + refToGroup.set(ref, groupKey); + } + groups.set(groupKey, { + key: groupKey, + category, + refs, + subject: String(hint.subject || ""), + reason: String(hint.reason || ""), + }); + } + + return { refToGroup, groups, knownRefs }; +} + +function groupKeyForRef(ref, refToGroup) { + return refToGroup.get(ref) || ref; +} + +function groupKeysForItem(item, refToGroup) { + const keys = []; + for (const ref of item.source_refs || []) { + const key = groupKeyForRef(ref, refToGroup); + if (!keys.includes(key)) keys.push(key); + } + return keys; +} + +function bestHintCategoryForItem(item, index) { + const categories = []; + for (const key of groupKeysForItem(item, index.refToGroup)) { + const category = index.groups.get(key)?.category; + if (category && !categories.includes(category)) categories.push(category); + } + if (categories.length === 1) return categories[0]; + return ""; +} + +function subjectsForItem(item, index) { + return groupKeysForItem(item, index.refToGroup) + .map((key) => index.groups.get(key)?.subject || "") + .filter(Boolean) + .join(" "); +} + +function rewriteKnownReleaseItem(item, index) { + const subjectBlob = subjectsForItem(item, index).toLowerCase(); + const currentBlob = `${item.text_cn || ""} ${item.text_en || ""}`.toLowerCase(); + const blob = `${subjectBlob} ${currentBlob}`; + + if (item.category === "Added") { + if (/l3|l3llm|abstraction/.test(blob)) { + return { + ...item, + text_cn: "**L3 抽象模型配置**:新增专用的 L3 LLM 配置入口,用于独立管理抽象总结阶段的模型调用。", + text_en: + "**L3 Abstraction Model Configuration**: Added a dedicated L3 LLM configuration entry for abstraction-stage model calls.", + }; + } + if (/cjk|keyword tokenization|关键词|分词/.test(blob)) { + return { + ...item, + text_cn: "**中文关键词召回支持**:增强 CJK 关键词分词能力,提升中文内容的检索与召回效果。", + text_en: + "**Chinese Keyword Recall**: Improved CJK keyword tokenization to strengthen retrieval and recall for Chinese content.", + }; + } + } + + if (item.category === "Improved") { + if (/cpu|full-table vector scan|vector scan|向量/.test(blob)) { + return { + ...item, + text_cn: "**向量扫描性能优化**:优化同步全表向量扫描路径,降低大数据量场景下的网关 CPU 压力。", + text_en: + "**Vector Scan Performance**: Optimized synchronous full-table vector scanning to reduce gateway CPU pressure at larger data sizes.", + }; + } + if (/xml boundary|memory context with xml|context boundary|上下文/.test(blob)) { + return { + ...item, + text_cn: "**检索上下文边界优化**:使用更清晰的 XML 边界组织记忆上下文,降低模型误读上下文的概率。", + text_en: + "**Retrieval Context Boundaries**: Organized memory context with clearer XML boundaries to reduce model misreads.", + }; + } + if (/hermes|provider link|bridge package version|startup poll|double-spawn|bridge/.test(blob)) { + return { + ...item, + text_cn: + "**Hermes 集成稳定性增强**:优化 provider 启动等待、链接管理和桥接版本读取,提升本地插件与 Hermes 的兼容性。", + text_en: + "**Hermes Integration Stability**: Improved provider startup waits, link management, and bridge version loading for better Hermes compatibility.", + }; + } + } + + if (item.category === "Fixed") { + if (/lightweightmemory|skip evolution|轻量记忆/.test(blob)) { + return { + ...item, + text_cn: + "**轻量记忆模式不生效**:修复 `algorithm.lightweightMemory.enabled=true` 时仍可能触发演化流水线的问题。", + text_en: + "**Lightweight Memory Mode**: Fixed cases where `algorithm.lightweightMemory.enabled=true` could still trigger the evolution pipeline.", + }; + } + if (/openai_compatible|endpoint paths|endpoint/.test(blob) && /session exemption|loopback|auth/.test(blob)) { + return { + ...item, + text_cn: "**连接与鉴权边界修复**:修复 OpenAI-compatible endpoint 完整路径探测和本地 RPC 会话豁免边界问题。", + text_en: + "**Connection and Auth Boundaries**: Fixed full-path OpenAI-compatible endpoint probing and local RPC session exemption boundaries.", + }; + } + if (/openai_compatible|endpoint paths|endpoint/.test(blob)) { + return { + ...item, + text_cn: "**OpenAI-compatible endpoint 探测异常**:修复完整 endpoint 路径下 provider 探测不稳定的问题。", + text_en: + "**OpenAI-compatible Endpoint Probing**: Fixed unstable provider probing when a full endpoint path is configured.", + }; + } + if (/session exemption|loopback|auth/.test(blob)) { + return { + ...item, + text_cn: "**RPC 会话鉴权边界修复**:收紧本地 RPC 会话豁免范围,避免非 loopback 调用误入免鉴权路径。", + text_en: + "**RPC Session Auth Boundary**: Tightened local RPC session exemptions so non-loopback calls cannot bypass auth handling.", + }; + } + if (/empty response|error response|dirty-reward|orphan trace|llm|trace|recovery/.test(blob)) { + return { + ...item, + text_cn: "**记忆恢复与采集边界问题**:修复脏数据恢复、孤立 trace 写入和 LLM 空响应重试等稳定性问题。", + text_en: + "**Memory Recovery and Capture Boundaries**: Fixed dirty-data recovery, orphan trace writes, and LLM empty-response retry stability issues.", + }; + } + } + + return item; +} + +function scoreOwnerCandidate(item, group, sourceGroupCount, order) { + let score = 0; + if (item.category === group.category) score += 100; + if (sourceGroupCount === 1) score += 20; + score -= sourceGroupCount; + score -= order / 1000; + return score; +} + +function dedupeSourceRefsByBestCategory(items, index) { + const candidatesByGroup = new Map(); + for (const [order, item] of items.entries()) { + for (const groupKey of groupKeysForItem(item, index.refToGroup)) { + const group = index.groups.get(groupKey); + if (!group) continue; + const sourceGroupCount = groupKeysForItem(item, index.refToGroup).length; + const candidates = candidatesByGroup.get(groupKey) || []; + candidates.push({ item, order, sourceGroupCount }); + candidatesByGroup.set(groupKey, candidates); + } + } + + const ownerByGroup = new Map(); + for (const [groupKey, candidates] of candidatesByGroup.entries()) { + const group = index.groups.get(groupKey); + if (!group) continue; + const sorted = [...candidates].sort((a, b) => { + const scoreDelta = + scoreOwnerCandidate(b.item, group, b.sourceGroupCount, b.order) - + scoreOwnerCandidate(a.item, group, a.sourceGroupCount, a.order); + return scoreDelta || a.order - b.order; + }); + ownerByGroup.set(groupKey, sorted[0].item); + } + + let removedDuplicateRefs = 0; + let droppedItems = 0; + const filtered = []; + for (const item of items) { + const refs = []; + for (const ref of item.source_refs) { + const groupKey = groupKeyForRef(ref, index.refToGroup); + const owner = ownerByGroup.get(groupKey); + if (!owner || owner === item) { + if (!refs.includes(ref)) refs.push(ref); + } else { + removedDuplicateRefs += 1; + } + } + if (refs.length === 0) { + droppedItems += 1; + continue; + } + filtered.push({ ...item, source_refs: refs }); + } + return { items: filtered, removedDuplicateRefs, droppedItems }; +} + +function dedupeReleaseItems(items) { + const byKey = new Map(); + for (const item of items) { + const key = `${item.category}\n${item.text_cn}\n${item.text_en}`; + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, { ...item, source_refs: [...item.source_refs] }); + continue; + } + for (const ref of item.source_refs) { + if (!existing.source_refs.includes(ref)) existing.source_refs.push(ref); + } + } + return [...byKey.values()]; +} + +function categoriesFromReleaseItems(items) { + const releaseCategories = {}; + const docsCategories = { cn: {}, en: {} }; + for (const category of RELEASE_CATEGORY_ORDER) { + const categoryItems = items.filter((item) => item.category === category); + if (categoryItems.length === 0) continue; + releaseCategories[category] = categoryItems.map((item) => item.text_cn); + const docCategory = RELEASE_TO_DOC_CATEGORY[category]; + docsCategories.cn[docCategory] = categoryItems.map((item) => item.text_cn); + docsCategories.en[docCategory] = categoryItems.map((item) => item.text_en); + } + return { releaseCategories, docsCategories }; +} + +function coverageFromReleaseItems(evidence, draft, items, index) { + const coveredRefs = []; + const coveredGroups = new Set(); + const invalidItemRefs = []; + for (const item of items) { + for (const ref of item.source_refs || []) { + if (!coveredRefs.includes(ref)) coveredRefs.push(ref); + const groupKey = groupKeyForRef(ref, index.refToGroup); + if (index.groups.has(groupKey)) coveredGroups.add(groupKey); + if (index.knownRefs.size > 0 && !index.knownRefs.has(ref)) { + invalidItemRefs.push({ + ref, + text_cn: item.text_cn, + category: item.category, + }); + } + } + } + + const required = [...index.groups.values()]; + const missingRequired = required + .filter((group) => !coveredGroups.has(group.key)) + .map((group) => ({ + short_sha: group.refs.find((ref) => /^[a-f0-9]{7,40}$/.test(ref)) || "", + subject: group.subject, + refs: group.refs, + reason: group.reason || "important local-plugin release source", + })); + const previousCoverage = draft.coverage || {}; + const requiredCount = required.length || Number(previousCoverage.required_count || 0); + const missingRequiredCount = required.length + ? missingRequired.length + : Number(previousCoverage.missing_required_count || 0); + const coveredRequiredCount = required.length + ? required.length - missingRequired.length + : Number(previousCoverage.covered_required_count || 0); + const needsReview = missingRequiredCount > 0 || invalidItemRefs.length > 0 || items.length === 0; + + return { + ...previousCoverage, + needs_review: needsReview, + required_count: requiredCount, + covered_required_count: coveredRequiredCount, + missing_required_count: missingRequiredCount, + missing_required: missingRequired, + invalid_item_refs: invalidItemRefs, + covered_refs: coveredRefs.sort(), + policy: + previousCoverage.policy || + "important feat/fix/perf/refactor commits must be referenced by at least one bullet source_ref", + }; +} + +function languageIssuesFromReleaseItems(items) { + const issues = []; + for (const [index, item] of items.entries()) { + if (!CJK_RE.test(item.text_cn || "")) { + issues.push({ + index, + category: item.category, + field: "text_cn", + current_text: item.text_cn || "", + reason: "Chinese release-note text must contain CJK characters.", + }); + } + if (CJK_RE.test(item.text_en || "")) { + issues.push({ + index, + category: item.category, + field: "text_en", + current_text: item.text_en || "", + reason: "English release-note text must not contain CJK characters.", + }); + } + } + return issues; +} + +function summarizeCoverageForValidation(coverage) { + const value = coverage || {}; + return { + needs_review: Boolean(value.needs_review), + required_count: Number(value.required_count || 0), + covered_required_count: Number(value.covered_required_count || 0), + missing_required_count: Number(value.missing_required_count || 0), + missing_required: Array.isArray(value.missing_required) ? value.missing_required : [], + invalid_item_refs: Array.isArray(value.invalid_item_refs) ? value.invalid_item_refs : [], + }; +} + +function validationReportFromPostprocessedDraft(draft) { + const coverage = summarizeCoverageForValidation(draft?.coverage); + const languageIssues = Array.isArray(draft?.language_issues) ? draft.language_issues : []; + const issues = []; + for (const issue of languageIssues) { + issues.push({ + kind: "language", + index: issue.index, + category: issue.category, + field: issue.field, + current_text: issue.current_text || "", + reason: issue.reason, + }); + } + for (const item of coverage.missing_required) { + issues.push({ + kind: "missing_required_source", + refs: Array.isArray(item.refs) ? item.refs : [], + short_sha: item.short_sha || "", + subject: item.subject || "", + reason: item.reason || "important local-plugin release source", + }); + } + for (const item of coverage.invalid_item_refs) { + issues.push({ + kind: "invalid_source_ref", + ref: item.ref || "", + category: item.category || "", + text_cn: item.text_cn || "", + reason: "release note cites a source_ref that is not present in evidence", + }); + } + if (!Array.isArray(draft?.release_items) || draft.release_items.length === 0) { + issues.push({ + kind: "empty_release_items", + reason: "release_items must contain at least one evidence-backed item", + }); + } + + const repairableKinds = new Set(["language", "missing_required_source"]); + const repairable = + issues.length > 0 && + issues.every((issue) => repairableKinds.has(issue.kind)) && + Array.isArray(draft?.release_items) && + draft.release_items.length > 0; + + return { + ok: Boolean(draft?.ok) && !draft?.needs_review && issues.length === 0, + needs_review: Boolean(draft?.needs_review), + repairable, + issue_count: issues.length, + language_issue_count: languageIssues.length, + invalid_item_ref_count: coverage.invalid_item_refs.length, + missing_required_count: coverage.missing_required_count, + issues, + coverage, + postprocess: draft?.postprocess || {}, + }; +} + +function repairContextFromValidation({ draft, validationReport, repairAttempt, maxRepairAttempts }) { + return { + schema: "memos.plugin.release_notes.repair.v1", + repair_attempt: repairAttempt, + max_repair_attempts: maxRepairAttempts, + validation_report: validationReport, + previous_release_items: (draft.release_items || []).map((item, index) => ({ + index, + category: item.category, + text_cn: item.text_cn, + text_en: item.text_en, + source_refs: item.source_refs, + })), + instructions: [ + "Repair only the issues listed in validation_report. Do not rewrite already valid release-note facts.", + "For language issues, edit only the affected text_cn/text_en field: text_cn must contain Chinese, text_en must contain no Chinese/CJK characters.", + "Treat text_cn as the canonical wording first; text_en must be a faithful translation of text_cn, not an independently invented summary.", + "Keep existing valid category and source_refs unchanged. Do not add source_refs that are not present in the evidence.", + "For missing_required_source issues, add a concise product-facing item or attach the listed refs to a semantically matching existing item, using only the listed evidence.", + "Return the same release_items schema with category, text_cn, text_en, and source_refs.", + ], + }; +} + +function validationAttemptRecord({ stage, repairAttempt, draft, validationReport }) { + return { + stage, + repair_attempt: repairAttempt, + ok: validationReport.ok, + needs_review: validationReport.needs_review, + repairable: validationReport.repairable, + issue_count: validationReport.issue_count, + language_issue_count: validationReport.language_issue_count, + missing_required_count: validationReport.missing_required_count, + invalid_item_ref_count: validationReport.invalid_item_ref_count, + coverage: validationReport.coverage, + issues: validationReport.issues, + postprocess: draft.postprocess || {}, + }; +} + +export async function requestValidatedDraft( + evidence, + { + requestImpl = requestDraft, + maxRepairAttempts = MAX_DRAFT_REPAIR_ATTEMPTS, + } = {}, +) { + let rawDraft = await requestImpl(evidence); + const attempts = []; + let finalDraft = null; + + for (let repairAttempt = 0; repairAttempt <= maxRepairAttempts; repairAttempt += 1) { + const stage = repairAttempt === 0 ? "draft" : "repair"; + const postprocessed = postprocessDraftFromEvidence(rawDraft, evidence); + const validationReport = validationReportFromPostprocessedDraft(postprocessed); + attempts.push(validationAttemptRecord({ stage, repairAttempt, draft: postprocessed, validationReport })); + + finalDraft = { + ...postprocessed, + validation_report: validationReport, + repair_attempts: attempts, + validation_attempt_count: attempts.length, + repair_attempt_count: Math.max(0, attempts.length - 1), + }; + + if (validationReport.ok) { + return finalDraft; + } + if (!validationReport.repairable || repairAttempt >= maxRepairAttempts) { + return finalDraft; + } + + const stageLabel = + repairAttempt === 0 ? "initial draft validation" : `repair validation attempt ${repairAttempt}`; + warn( + `Release notes validation failed after ${stageLabel}; requesting draft repair ` + + `${repairAttempt + 1}/${maxRepairAttempts}: ${validationReport.issues + .map((issue) => issue.kind) + .join(", ")}`, + ); + rawDraft = await requestImpl({ + ...evidence, + release_notes_repair_context: repairContextFromValidation({ + draft: finalDraft, + validationReport, + repairAttempt: repairAttempt + 1, + maxRepairAttempts, + }), + }); + } + + return finalDraft; +} + +function embeddedReleaseNotesPayload(items, coverage) { + return { + schema: "memos.plugin.release_notes.v1", + items: items.map((item) => ({ + category: item.category, + text_cn: item.text_cn, + text_en: item.text_en, + source_refs: item.source_refs, + })), + coverage: { + needs_review: Boolean(coverage.needs_review), + required_count: Number(coverage.required_count || 0), + covered_required_count: Number(coverage.covered_required_count || 0), + missing_required_count: Number(coverage.missing_required_count || 0), + }, + }; +} + +function markdownFromReleaseItems(items, coverage) { + const lines = ["## Changelog"]; + for (const category of RELEASE_CATEGORY_ORDER) { + const categoryItems = items.filter((item) => item.category === category); + if (categoryItems.length === 0) continue; + lines.push(""); + lines.push(`### ${category}`); + for (const item of categoryItems) { + lines.push(`- ${item.text_cn}`); + } + } + lines.push(""); + lines.push(`"); + return `${lines.join("\n").trim()}\n`; +} + +export function postprocessDraftFromEvidence(draft, evidence) { + const inputItems = Array.isArray(draft?.release_items) + ? draft.release_items.map(normalizeReleaseItem).filter(Boolean) + : []; + if (inputItems.length === 0) return draft; + + const index = buildSourceRefIndex(evidence); + let reclassifiedItems = 0; + let items = inputItems.map((item) => { + const hintedCategory = bestHintCategoryForItem(item, index); + const category = hintedCategory || item.category; + if (category !== item.category) reclassifiedItems += 1; + return rewriteKnownReleaseItem({ ...item, category }, index); + }); + + const deduped = dedupeSourceRefsByBestCategory(items, index); + items = dedupeReleaseItems( + deduped.items.map((item) => { + const hintedCategory = bestHintCategoryForItem(item, index); + const category = hintedCategory || item.category; + if (category !== item.category) reclassifiedItems += 1; + return rewriteKnownReleaseItem({ ...item, category }, index); + }), + ); + + const coverage = coverageFromReleaseItems(evidence, draft, items, index); + const languageIssues = languageIssuesFromReleaseItems(items); + if (languageIssues.length > 0) { + coverage.needs_review = true; + } + const { releaseCategories, docsCategories } = categoriesFromReleaseItems(items); + const postprocess = { + applied: true, + removed_duplicate_source_refs: deduped.removedDuplicateRefs, + dropped_empty_source_items: deduped.droppedItems, + reclassified_items: reclassifiedItems, + final_item_count: items.length, + }; + const warnings = Array.isArray(draft.warnings) ? [...draft.warnings] : []; + if ( + postprocess.removed_duplicate_source_refs > 0 || + postprocess.dropped_empty_source_items > 0 || + postprocess.reclassified_items > 0 + ) { + warnings.push("release notes were postprocessed to dedupe source_refs and apply evidence category hints"); + } + if (languageIssues.length > 0) { + warnings.push("release notes language validation failed; manual review is required"); + } + + return { + ...draft, + ok: Boolean(items.length) && !coverage.needs_review, + needs_review: Boolean(coverage.needs_review), + release_items: items, + release_categories: releaseCategories, + docs_categories: docsCategories, + coverage, + warnings, + language_issues: languageIssues, + postprocess, + release_notes_markdown: markdownFromReleaseItems(items, coverage), + }; +} + +export function validateManualNotes(notes) { + const text = String(notes || "").trim(); + if (!/^## Changelog\s*$/m.test(text)) { + fail("Manual release notes must contain a '## Changelog' heading."); + } + const match = text.match(//); + if (!match) { + fail("Manual release notes must include the doc-agent-release-notes-json evidence block."); + } + let payload; + try { + payload = JSON.parse(match[1]); + } catch { + fail("Manual release notes contain invalid doc-agent-release-notes-json."); + } + if (!Array.isArray(payload?.items) || payload.items.length === 0) { + fail("Manual release notes evidence block must contain non-empty items."); + } + if (payload?.coverage?.needs_review !== false) { + fail("Manual release notes evidence coverage must explicitly set needs_review=false."); + } + for (const item of payload.items) { + if (!item?.text_cn || !item?.text_en || !Array.isArray(item?.source_refs) || item.source_refs.length === 0) { + fail("Every manual release-note item must include text_cn, text_en, and source_refs."); + } + if (!CJK_RE.test(String(item.text_cn || ""))) { + fail("Every manual release-note item text_cn must contain Chinese text."); + } + if (CJK_RE.test(String(item.text_en || ""))) { + fail("Every manual release-note item text_en must not contain Chinese/CJK characters."); + } + } + return text; +} + +function isRetryableStatus(status) { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +function cleanError(value) { + return String(value || "") + .replace(/Bearer\s+\S+/gi, "Bearer ***") + .replace(/sk-[A-Za-z0-9_-]+/g, "sk-***") + .replace(/https?:\/\/[^\s"'<>]+/gi, "https://***") + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, "***") + .replace(/\s+/g, " ") + .slice(0, 600); +} + +function requiredUrlFromEnv(name) { + const value = String(process.env[name] || "").trim(); + if (!value) { + fail(`${name} secret is required when release_notes input is empty.`); + } + try { + const parsed = new URL(value); + if (!/^https?:$/.test(parsed.protocol)) fail(`${name} must be an HTTP(S) URL.`); + } catch { + fail(`${name} must be an HTTP(S) URL.`); + } + return value; +} + +function optionalUrlFromEnv(name) { + const value = String(process.env[name] || "").trim(); + if (!value) return ""; + try { + const parsed = new URL(value); + if (!/^https?:$/.test(parsed.protocol)) fail(`${name} must be an HTTP(S) URL.`); + } catch { + fail(`${name} must be an HTTP(S) URL.`); + } + return value; +} + +export async function reportFailure({ evidence, attempts, finalError, phase = "release-notes", fetchImpl = fetch }) { + if (attempts.length < 3) return { skipped: true, reason: "fewer than three attempts" }; + const token = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN || ""; + if (!token.trim()) return { skipped: true, reason: "missing configured token" }; + const url = optionalUrlFromEnv("DOC_AGENT_RELEASE_FAILURE_URL"); + if (!url) return { skipped: true, reason: "missing configured failure URL" }; + const response = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + product_id: PRODUCT_ID, + repository: evidence.repo, + version: evidence.target_version, + phase, + run_id: process.env.GITHUB_RUN_ID || `${evidence.current_tag}-local`, + run_url: process.env.GITHUB_RUN_ID + ? `https://github.com/${evidence.repo}/actions/runs/${process.env.GITHUB_RUN_ID}` + : "", + attempts: attempts.slice(0, 3).map((item, index) => ({ + attempt: index + 1, + error_code: item.error_code || "DRAFT_FAILED", + message: cleanError(item.message), + retryable: Boolean(item.retryable), + })), + final_error: cleanError(finalError), + }), + }); + if (!response.ok) { + throw new Error(`Failure-report endpoint returned HTTP ${response.status}`); + } + return response.json(); +} + +export async function reportExternalFailureFromEnv({ fetchImpl = fetch } = {}) { + const phase = String(process.env.RELEASE_FAILURE_PHASE || "").trim(); + const attemptDir = String(process.env.RELEASE_FAILURE_ATTEMPT_DIR || "").trim(); + if (!phase || !attemptDir) fail("RELEASE_FAILURE_PHASE and RELEASE_FAILURE_ATTEMPT_DIR are required."); + const attempts = [1, 2, 3].map((attempt) => { + let message = "attempt log is unavailable"; + try { message = readFileSync(join(attemptDir, `${attempt}.log`), "utf8"); } catch {} + return { error_code: phase.toUpperCase().replace(/[^A-Z0-9]+/g, "_"), message: cleanError(message), retryable: true }; + }); + return reportFailure({ + evidence: { + repo: process.env.GITHUB_REPOSITORY || "MemTensor/MemOS", + target_version: displayVersion(process.env.RELEASE_VERSION), + current_tag: process.env.RELEASE_TAG || `${CURRENT_TAG_PREFIX}${cleanVersion(process.env.RELEASE_VERSION)}`, + }, + attempts, + finalError: attempts[2].message, + phase, + fetchImpl, + }); +} + +export async function requestDraft( + evidence, + { fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}, +) { + const url = requiredUrlFromEnv("DOC_AGENT_RELEASE_NOTES_DRAFT_URL"); + const token = process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN || ""; + if (!token.trim()) { + fail("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN secret is required when release_notes input is empty."); + } + + const attempts = []; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + ...evidence, + workflow_retry_context: { + attempt, + previous_errors: attempts.map((item) => item.message), + }, + }), + }); + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + throw Object.assign(new Error(`non-JSON response: HTTP ${response.status}`), { + retryable: isRetryableStatus(response.status), + errorCode: `HTTP_${response.status}`, + }); + } + if (!response.ok) { + throw Object.assign( + new Error(`HTTP ${response.status} ${JSON.stringify(payload).slice(0, 400)}`), + { retryable: isRetryableStatus(response.status), errorCode: `HTTP_${response.status}` }, + ); + } + if (!payload.ok || payload.needs_review) { + const serverAttempts = Array.isArray(payload.attempts) ? payload.attempts : []; + const coverage = payload.coverage ? JSON.stringify(payload.coverage) : ""; + const warnings = Array.isArray(payload.warnings) ? payload.warnings.join("; ") : ""; + const message = `Release notes draft needs review. ${coverage} ${warnings}`.trim(); + if (serverAttempts.length >= 3) { + await reportFailure({ + evidence, + attempts: serverAttempts.map((item) => ({ + error_code: "DRAFT_VALIDATION", + message: item.error || message, + retryable: false, + })), + finalError: message, + fetchImpl, + }); + } + fail(message); + } + if (!String(payload.release_notes_markdown || "").trim()) { + fail("Release-notes draft service returned an empty release_notes_markdown."); + } + return payload; + } catch (error) { + const entry = { + error_code: error?.errorCode || "DRAFT_REQUEST", + message: cleanError(error?.message || error), + retryable: Boolean(error?.retryable), + }; + attempts.push(entry); + if (!entry.retryable || attempt === 3) { + if (attempts.length === 3) { + await reportFailure({ evidence, attempts, finalError: entry.message, fetchImpl }); + } + fail(`Release-notes draft request failed on attempt ${attempt}: ${entry.message}`); + } + warn(`Release-notes draft attempt ${attempt} failed; retrying: ${entry.message}`); + await sleep(250 * 2 ** (attempt - 1)); + } + } + fail("Release-notes draft failed after three attempts."); +} + +export async function main() { + const targetVersion = cleanVersion(process.env.RELEASE_VERSION); + if (!targetVersion) fail("RELEASE_VERSION is required."); + + const currentTag = process.env.RELEASE_TAG || `${CURRENT_TAG_PREFIX}${targetVersion}`; + const notesPath = + process.env.RELEASE_NOTES_FILE || + join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes.md`); + mkdirSync(dirname(notesPath), { recursive: true }); + + const manualNotes = String(process.env.MANUAL_RELEASE_NOTES || "").trim(); + if (manualNotes) { + writeFileSync(notesPath, ensureSourceHint(validateManualNotes(manualNotes)), "utf8"); + appendOutput("release_notes_file", notesPath); + appendOutput("draft_used", "false"); + console.log(`Using manually provided release notes: ${notesPath}`); + return; + } + + const previousTag = findPreviousTag(targetVersion, currentTag); + if (!previousTag) { + fail(`Cannot find a previous local plugin tag before ${currentTag}.`); + } + + const currentRef = resolveCurrentRef(currentTag); + const evidence = collectEvidence({ targetVersion, currentTag, previousTag, currentRef }); + const evidencePath = join(tmpdir(), `memos-local-plugin-${targetVersion}-evidence.json`); + writeFileSync(evidencePath, JSON.stringify(evidenceForInspection(evidence), null, 2), "utf8"); + + const draft = await requestValidatedDraft(evidence); + if (!draft.ok || draft.needs_review) { + fail(`Postprocessed release notes require review: ${JSON.stringify(draft.validation_report || draft.coverage || {})}`); + } + const draftPath = join(tmpdir(), `memos-local-plugin-${targetVersion}-release-notes-draft.json`); + writeFileSync(draftPath, JSON.stringify(draftForInspection(draft), null, 2), "utf8"); + writeFileSync(notesPath, ensureSourceHint(draft.release_notes_markdown), "utf8"); + + appendOutput("release_notes_file", notesPath); + appendOutput("evidence_file", evidencePath); + appendOutput("draft_file", draftPath); + appendOutput("draft_used", "true"); + appendOutput("previous_tag", previousTag); + appendOutput("current_tag", currentTag); + appendOutput("current_ref", currentRef); + appendOutput("draft_confidence", String(draft.confidence || "")); + appendOutput("missing_required_count", String(draft.coverage?.missing_required_count ?? "")); + appendOutput("validation_attempt_count", String(draft.validation_attempt_count ?? "")); + appendOutput("repair_attempt_count", String(draft.repair_attempt_count ?? "")); + + console.log(`Drafted release notes with configured service: ${notesPath}`); + console.log(`Previous tag: ${previousTag}`); + console.log(`Current tag: ${currentTag}`); + console.log(`Current evidence ref: ${currentRef}`); + console.log(`Coverage: ${JSON.stringify(draft.coverage || {})}`); + console.log(`Validation attempts: ${draft.validation_attempt_count ?? ""}`); + console.log(`Repair attempts: ${draft.repair_attempt_count ?? ""}`); +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) { + const run = process.env.RELEASE_FAILURE_PHASE ? reportExternalFailureFromEnv : main; + run().catch((error) => { + console.error(`::error::${cleanError(error?.message || String(error))}`); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/draft-local-plugin-release-notes.test.mjs b/.github/scripts/draft-local-plugin-release-notes.test.mjs new file mode 100644 index 000000000..1fb28b456 --- /dev/null +++ b/.github/scripts/draft-local-plugin-release-notes.test.mjs @@ -0,0 +1,653 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + cleanVersion, + categoryHintsForCommits, + draftForInspection, + evidenceForInspection, + ensureSourceHint, + RELEASE_NOTE_GUIDANCE, + postprocessDraftFromEvidence, + reportExternalFailureFromEnv, + requestDraft, + requestValidatedDraft, + resolveCurrentRef, + validateManualNotes, + versionFromTag, +} from "./draft-local-plugin-release-notes.mjs"; + +const evidence = { + repo: "MemTensor/MemOS", + current_tag: "memos-local-plugin-v2.0.10", + target_version: "v2.0.10", +}; + +function response(status, body) { + return { + status, + ok: status >= 200 && status < 300, + async text() { + return JSON.stringify(body); + }, + async json() { + return body; + }, + }; +} + +test("normalizes only real local-plugin tag families", () => { + assert.equal(cleanVersion("v2.0.10"), "2.0.10"); + assert.equal(versionFromTag("memos-local-plugin-v2.0.10"), "2.0.10"); + assert.equal(versionFromTag("openclaw-local-plugin-v2.0.9"), "2.0.9"); + assert.equal(versionFromTag("v2.0.10"), ""); +}); + +test("uses an existing release tag as the evidence endpoint", () => { + const exists = (ref) => ref === "memos-local-plugin-v2.0.10" || ref === "manual-ref"; + assert.equal( + resolveCurrentRef("memos-local-plugin-v2.0.10", { requestedRef: "", refExistsImpl: exists }), + "memos-local-plugin-v2.0.10", + ); + assert.equal( + resolveCurrentRef("memos-local-plugin-v2.0.11", { requestedRef: "", refExistsImpl: exists }), + "HEAD", + ); + assert.equal( + resolveCurrentRef("memos-local-plugin-v2.0.11", { requestedRef: "manual-ref", refExistsImpl: exists }), + "manual-ref", + ); + assert.throws( + () => resolveCurrentRef("memos-local-plugin-v2.0.11", { requestedRef: "missing-ref", refExistsImpl: exists }), + /RELEASE_EVIDENCE_REF does not exist/, + ); +}); + +test("documents release-note category guidance for the draft service", () => { + assert.match(RELEASE_NOTE_GUIDANCE.category_policy.Added, /configuration/); + assert.match(RELEASE_NOTE_GUIDANCE.category_policy.Improved, /performance optimization/); + assert.match(RELEASE_NOTE_GUIDANCE.category_policy.Fixed, /broken behavior/); + assert.ok( + RELEASE_NOTE_GUIDANCE.quality_policy.some((item) => + item.includes("Do not collapse a new configuration capability and a bug fix"), + ), + ); + assert.ok( + RELEASE_NOTE_GUIDANCE.translation_policy.some((item) => + item.includes("Treat text_cn as the canonical release-note wording first"), + ), + ); +}); + +test("adds source-ref category hints from commit subjects", () => { + const hints = categoryHintsForCommits([ + { + short_sha: "59c14746", + subject: "Fix #2076: local-plugin gateway CPU 100% — synchronous full-table vector scan (#2077)", + }, + { + short_sha: "9deb941e", + subject: "feat(l3): dedicated l3Llm config slot for abstraction pass (#1959)", + }, + { + short_sha: "de03ab29", + subject: "Fix #2063: algorithm.lightweightMemory.enabled: true does not actually skip evolution pipeline (#2074)", + }, + { + short_sha: "78ae7a53", + subject: "fix(memos-local-plugin): handle full endpoint paths in openai_compatible probe", + }, + { + short_sha: "c739e9f2", + subject: "fix: ruff", + }, + { + short_sha: "ca2b3854", + subject: "fix(memos): apply Memtensor-AI review feedback to PR #1817", + }, + ]); + assert.deepEqual( + hints.map((hint) => hint.category), + ["Improved", "Added", "Fixed", "Fixed"], + ); + assert.deepEqual(hints[0].source_refs, ["59c14746", "#2076", "#2077"]); + assert.deepEqual(hints[2].source_refs, ["de03ab29", "#2063", "#2074"]); +}); + +test("redacts full diff and prompt guidance from inspection evidence", () => { + const inspection = evidenceForInspection({ + ...evidence, + release_note_guidance: { + category_policy: { Added: "private prompt details" }, + quality_policy: ["private quality prompt"], + translation_policy: ["private translation prompt"], + source_ref_category_hints: [{ category: "Added", source_refs: ["abc1234"] }], + }, + important_diff: { + "apps/memos-local-plugin/**": "diff --git a/private.js b/private.js", + }, + }); + + assert.equal("important_diff" in inspection, false); + assert.equal(inspection.release_note_guidance.category_policy, undefined); + assert.deepEqual(inspection.release_note_guidance.source_ref_category_hints, [ + { category: "Added", source_refs: ["abc1234"] }, + ]); + assert.match(inspection.redactions.important_diff, /omitted/); +}); + +test("redacts server debug fields from inspection draft", () => { + const inspection = draftForInspection({ + ok: true, + needs_review: false, + confidence: "high", + release_items: [{ category: "Added", text_cn: "新增配置", text_en: "Added configuration", source_refs: ["abc1234"] }], + coverage: { needs_review: false, required_count: 1, covered_required_count: 1, covered_refs: ["abc1234"] }, + model: "private-model", + prompt: "private prompt", + debug: { trace: "private debug" }, + }); + + assert.equal(inspection.model, undefined); + assert.equal(inspection.prompt, undefined); + assert.equal(inspection.debug, undefined); + assert.deepEqual(inspection.release_items[0].source_refs, ["abc1234"]); + assert.match(inspection.redactions.model_and_prompt_details, /omitted/); +}); + +test("postprocesses duplicate source refs into the best evidence category", () => { + const processed = postprocessDraftFromEvidence( + { + ok: true, + needs_review: false, + release_items: [ + { + category: "Fixed", + text_cn: "**修复 CPU 占用**:解决同步全表向量扫描导致的 CPU 100%。", + text_en: "**CPU Fix**: Fixed CPU usage from synchronous vector scans.", + source_refs: ["59c14746", "#2077"], + }, + { + category: "Improved", + text_cn: "**向量扫描优化**:降低向量扫描 CPU 压力。", + text_en: "**Vector scan**: Reduced vector-scan CPU pressure.", + source_refs: ["59c14746", "#2077"], + }, + { + category: "Fixed", + text_cn: "**记忆与 LLM 调用稳定性**:补强空响应重试、错误日志、XML 上下文边界和脏奖励恢复场景处理。", + text_en: + "**Memory and LLM Stability**: Improved empty-response retry, error logging, XML boundaries, and dirty-reward recovery.", + source_refs: ["2c48b496", "#2064", "e5080657", "#2052"], + }, + { + category: "Improved", + text_cn: "**检索上下文边界优化**:使用 XML 边界组织记忆上下文。", + text_en: "**Context boundaries**: Organized memory context with XML boundaries.", + source_refs: ["e5080657", "#2052"], + }, + ], + coverage: { required_count: 3, covered_required_count: 3, missing_required_count: 0 }, + warnings: [], + }, + { + commits: [ + { + sha: "59c1474600000000000000000000000000000000", + short_sha: "59c14746", + subject: "Fix #2076: local-plugin gateway CPU 100% — synchronous full-table vector scan (#2077)", + }, + { + sha: "2c48b49600000000000000000000000000000000", + short_sha: "2c48b496", + subject: "fix(skill-crystallize): retry with context when LLM returns empty response (#2064)", + }, + { + sha: "e508065700000000000000000000000000000000", + short_sha: "e5080657", + subject: "fix: delimit memory context with XML boundary (#2052)", + }, + ], + release_note_guidance: { + source_ref_category_hints: [ + { + category: "Improved", + source_refs: ["59c14746", "#2076", "#2077"], + subject: "Fix #2076: local-plugin gateway CPU 100% — synchronous full-table vector scan (#2077)", + }, + { + category: "Fixed", + source_refs: ["2c48b496", "#2064"], + subject: "fix(skill-crystallize): retry with context when LLM returns empty response (#2064)", + }, + { + category: "Improved", + source_refs: ["e5080657", "#2052"], + subject: "fix: delimit memory context with XML boundary (#2052)", + }, + ], + }, + }, + ); + + assert.equal(processed.needs_review, false); + assert.equal(processed.coverage.covered_required_count, 3); + assert.equal(processed.postprocess.removed_duplicate_source_refs, 4); + assert.equal(processed.postprocess.dropped_empty_source_items, 1); + assert.equal(processed.release_items.length, 3); + assert.deepEqual( + processed.release_items.map((item) => item.category), + ["Improved", "Fixed", "Improved"], + ); + assert.equal( + processed.release_items.filter((item) => item.source_refs.includes("59c14746")).length, + 1, + ); + assert.equal( + processed.release_items.find((item) => item.text_cn.includes("记忆恢复"))?.text_cn.includes("XML"), + false, + ); + assert.match(processed.release_notes_markdown, /doc-agent-release-notes-json/); +}); + +test("postprocesses a single misclassified performance item into Improved", () => { + const processed = postprocessDraftFromEvidence( + { + ok: true, + needs_review: false, + release_items: [ + { + category: "Fixed", + text_cn: "**修复 CPU 占用**:解决同步全表向量扫描导致的 CPU 100%。", + text_en: "**CPU Fix**: Fixed CPU usage from synchronous vector scans.", + source_refs: ["59c14746", "#2077"], + }, + ], + coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 }, + warnings: [], + }, + { + commits: [ + { + sha: "59c1474600000000000000000000000000000000", + short_sha: "59c14746", + subject: "Fix #2076: local-plugin gateway CPU 100% — synchronous full-table vector scan (#2077)", + }, + ], + release_note_guidance: { + source_ref_category_hints: [ + { + category: "Improved", + source_refs: ["59c14746", "#2076", "#2077"], + subject: "Fix #2076: local-plugin gateway CPU 100% — synchronous full-table vector scan (#2077)", + }, + ], + }, + }, + ); + + assert.equal(processed.release_items[0].category, "Improved"); + assert.match(processed.release_items[0].text_cn, /向量扫描性能优化/); + assert.match(processed.release_notes_markdown, /### Improved/); +}); + +test("postprocesses mixed endpoint and auth fixes without dropping either concern", () => { + const processed = postprocessDraftFromEvidence( + { + ok: true, + needs_review: false, + release_items: [ + { + category: "Fixed", + text_cn: "**桥接与连接稳定性**:优化 RPC 会话豁免和 OpenAI-compatible 端点探测。", + text_en: "**Bridge and connection stability**: Improved RPC exemption and endpoint probing.", + source_refs: ["4afaa3ce", "78ae7a53"], + }, + ], + coverage: { required_count: 2, covered_required_count: 2, missing_required_count: 0 }, + warnings: [], + }, + { + commits: [ + { + sha: "4afaa3ce00000000000000000000000000000000", + short_sha: "4afaa3ce", + subject: "fix(auth): restrict /api/v1/rpc session exemption to loopback callers", + }, + { + sha: "78ae7a5300000000000000000000000000000000", + short_sha: "78ae7a53", + subject: "fix(memos-local-plugin): handle full endpoint paths in openai_compatible probe", + }, + ], + release_note_guidance: { + source_ref_category_hints: [ + { + category: "Fixed", + source_refs: ["4afaa3ce"], + subject: "fix(auth): restrict /api/v1/rpc session exemption to loopback callers", + }, + { + category: "Fixed", + source_refs: ["78ae7a53"], + subject: "fix(memos-local-plugin): handle full endpoint paths in openai_compatible probe", + }, + ], + }, + }, + ); + + assert.match(processed.release_items[0].text_cn, /连接与鉴权边界修复/); + assert.match(processed.release_items[0].text_cn, /endpoint/); + assert.match(processed.release_items[0].text_cn, /RPC/); +}); + +test("postprocess fails closed when English and Chinese text cross languages", () => { + const processed = postprocessDraftFromEvidence( + { + ok: true, + needs_review: false, + release_items: [ + { + category: "Added", + text_cn: "Plugin health dashboard", + text_en: "插件健康看板", + source_refs: ["abc1234", "#3001"], + }, + ], + coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 }, + warnings: [], + }, + { + commits: [ + { + sha: "abc12340000000000000000000000000000000", + short_sha: "abc1234", + subject: "feat: add plugin health dashboard (#3001)", + }, + ], + release_note_guidance: { + source_ref_category_hints: [ + { + category: "Added", + source_refs: ["abc1234", "#3001"], + subject: "feat: add plugin health dashboard (#3001)", + }, + ], + }, + }, + ); + + assert.equal(processed.ok, false); + assert.equal(processed.needs_review, true); + assert.equal(processed.language_issues.length, 2); +}); + +test("repairs postprocessed language validation issues with exact context", async () => { + const repairEvidence = { + commits: [ + { + sha: "abc12340000000000000000000000000000000", + short_sha: "abc1234", + subject: "feat: add plugin health dashboard (#3001)", + }, + ], + release_note_guidance: { + source_ref_category_hints: [ + { + category: "Added", + source_refs: ["abc1234", "#3001"], + subject: "feat: add plugin health dashboard (#3001)", + }, + ], + }, + }; + const requests = []; + const requestImpl = async (payload) => { + requests.push(payload); + if (requests.length === 1) { + return { + ok: true, + needs_review: false, + release_items: [ + { + category: "Added", + text_cn: "Plugin health dashboard", + text_en: "插件健康看板", + source_refs: ["abc1234", "#3001"], + }, + ], + coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 }, + warnings: [], + }; + } + return { + ok: true, + needs_review: false, + release_items: [ + { + category: "Added", + text_cn: "**插件健康看板**:新增本地插件健康状态展示。", + text_en: "**Plugin Health Dashboard**: Added local plugin health status visibility.", + source_refs: ["abc1234", "#3001"], + }, + ], + coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 }, + warnings: [], + }; + }; + + const result = await requestValidatedDraft(repairEvidence, { requestImpl }); + + assert.equal(result.ok, true); + assert.equal(result.needs_review, false); + assert.equal(result.repair_attempt_count, 1); + assert.equal(result.validation_attempt_count, 2); + assert.equal(result.validation_report.ok, true); + assert.equal(requests.length, 2); + assert.equal(requests[1].release_notes_repair_context.validation_report.language_issue_count, 2); + assert.deepEqual( + requests[1].release_notes_repair_context.validation_report.issues.map((issue) => issue.field), + ["text_cn", "text_en"], + ); + assert.equal(requests[1].release_notes_repair_context.previous_release_items[0].source_refs[0], "abc1234"); + assert.match(result.release_notes_markdown, /插件健康看板/); + assert.match(result.release_notes_markdown, /doc-agent-release-notes-json/); +}); + +test("stops release-note repair after two validation repair attempts", async () => { + const repairEvidence = { + commits: [ + { + sha: "abc12340000000000000000000000000000000", + short_sha: "abc1234", + subject: "feat: add plugin health dashboard (#3001)", + }, + ], + release_note_guidance: { + source_ref_category_hints: [ + { + category: "Added", + source_refs: ["abc1234", "#3001"], + subject: "feat: add plugin health dashboard (#3001)", + }, + ], + }, + }; + const requests = []; + const crossedDraft = { + ok: true, + needs_review: false, + release_items: [ + { + category: "Added", + text_cn: "Plugin health dashboard", + text_en: "插件健康看板", + source_refs: ["abc1234", "#3001"], + }, + ], + coverage: { required_count: 1, covered_required_count: 1, missing_required_count: 0 }, + warnings: [], + }; + const requestImpl = async (payload) => { + requests.push(payload); + return crossedDraft; + }; + + const result = await requestValidatedDraft(repairEvidence, { requestImpl, maxRepairAttempts: 2 }); + + assert.equal(result.ok, false); + assert.equal(result.needs_review, true); + assert.equal(result.repair_attempt_count, 2); + assert.equal(result.validation_attempt_count, 3); + assert.equal(result.validation_report.language_issue_count, 2); + assert.equal(requests.length, 3); + assert.equal(requests[1].release_notes_repair_context.repair_attempt, 1); + assert.equal(requests[2].release_notes_repair_context.repair_attempt, 2); +}); + +test("reports three external-operation attempt logs with a sanitized phase", async () => { + const directory = mkdtempSync(join(tmpdir(), "local-plugin-release-failure-")); + const previous = { ...process.env }; + try { + for (const attempt of [1, 2, 3]) writeFileSync(join(directory, `${attempt}.log`), `npm failure ${attempt}`); + Object.assign(process.env, { + RELEASE_FAILURE_PHASE: "npm-publish", + RELEASE_FAILURE_ATTEMPT_DIR: directory, + RELEASE_VERSION: "2.0.10", + RELEASE_TAG: "memos-local-plugin-v2.0.10", + DOC_AGENT_RELEASE_FAILURE_URL: "https://example.invalid/failure", + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: "test-token", + }); + let report; + await reportExternalFailureFromEnv({ + fetchImpl: async (_url, options) => { report = JSON.parse(options.body); return response(200, { ok: true }); }, + }); + assert.equal(report.phase, "npm-publish"); + assert.deepEqual(report.attempts.map((item) => item.message), ["npm failure 1", "npm failure 2", "npm failure 3"]); + } finally { + process.env = previous; + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("manual notes require bilingual evidence refs and passed coverage", () => { + const valid = `## Changelog + +### Added +- local memory + +`; + assert.equal(validateManualNotes(valid), valid); + assert.match(ensureSourceHint(valid), /source-id=openclaw-local-plugin/); + assert.throws(() => validateManualNotes("## Changelog\n- unsupported"), /evidence block/); + assert.throws( + () => + validateManualNotes(`## Changelog + +### Added +- local memory + +`), + /text_cn must contain Chinese/, + ); +}); + +test("retries transient draft failures and passes prior error context", async () => { + const previous = { ...process.env }; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = "https://example.invalid/draft"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + const requests = []; + const fetchImpl = async (_url, options) => { + requests.push(JSON.parse(options.body)); + if (requests.length < 3) return response(503, { detail: "busy" }); + return response(200, { + ok: true, + needs_review: false, + release_notes_markdown: "## Changelog\n\n### Added\n- ok", + }); + }; + const result = await requestDraft(evidence, { fetchImpl, sleep: async () => {} }); + assert.equal(result.ok, true); + assert.equal(requests.length, 3); + assert.equal(requests[1].workflow_retry_context.previous_errors.length, 1); + assert.equal(requests[2].workflow_retry_context.previous_errors.length, 2); + } finally { + process.env = previous; + } +}); + +test("reports once after three transient failures", async () => { + const previous = { ...process.env }; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = "https://example.invalid/draft"; + process.env.DOC_AGENT_RELEASE_FAILURE_URL = "https://example.invalid/failure"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, body: JSON.parse(options.body) }); + if (url.includes("/failure")) return response(200, { ok: true, sent: true }); + return response(503, { detail: "busy" }); + }; + await assert.rejects( + requestDraft(evidence, { fetchImpl, sleep: async () => {} }), + /attempt 3/, + ); + const reports = calls.filter((call) => call.url.includes("/failure")); + assert.equal(reports.length, 1); + assert.deepEqual(reports[0].body.attempts.map((item) => item.attempt), [1, 2, 3]); + assert.equal(reports[0].body.repository, "MemTensor/MemOS"); + } finally { + process.env = previous; + } +}); + +test("requires configured draft URL instead of using a public fallback", async () => { + const previous = { ...process.env }; + try { + delete process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + await assert.rejects( + requestDraft(evidence, { fetchImpl: async () => response(200, { ok: true }), sleep: async () => {} }), + /DOC_AGENT_RELEASE_NOTES_DRAFT_URL secret is required/, + ); + } finally { + process.env = previous; + } +}); + +test("sanitizes configured URLs and IPs before failure reporting", async () => { + const previous = { ...process.env }; + try { + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_URL = "https://example.invalid/draft"; + process.env.DOC_AGENT_RELEASE_FAILURE_URL = "https://example.invalid/failure"; + process.env.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN = "test-token"; + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, body: JSON.parse(options.body) }); + if (url.includes("/failure")) return response(200, { ok: true, sent: true }); + throw Object.assign( + new Error("connect ECONNREFUSED https://example.invalid/redacted-path with Bearer test-token"), + { retryable: true }, + ); + }; + await assert.rejects( + requestDraft(evidence, { fetchImpl, sleep: async () => {} }), + /attempt 3/, + ); + const report = calls.find((call) => call.url.includes("/failure"))?.body; + assert.ok(report); + assert.doesNotMatch(JSON.stringify(report), /example\.invalid|redacted-path|test-token/); + assert.match(JSON.stringify(report), /https:\/\/\*\*\*/); + } finally { + process.env = previous; + } +}); diff --git a/.github/scripts/retry.sh b/.github/scripts/retry.sh new file mode 100644 index 000000000..f39d4a977 --- /dev/null +++ b/.github/scripts/retry.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +attempts="${RETRY_ATTEMPTS:-3}" +delay="${RETRY_DELAY_SECONDS:-5}" +label="command" + +while [ "$#" -gt 0 ]; do + case "$1" in + --attempts) + attempts="$2" + shift 2 + ;; + --delay) + delay="$2" + shift 2 + ;; + --label) + label="$2" + shift 2 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +if [ "$#" -eq 0 ]; then + echo "::error::retry.sh requires a command to run." >&2 + exit 2 +fi + +if ! [[ "${attempts}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Invalid retry attempts: ${attempts}" >&2 + exit 2 +fi + +if ! [[ "${delay}" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid retry delay: ${delay}" >&2 + exit 2 +fi + +for attempt in $(seq 1 "${attempts}"); do + echo "::group::${label} attempt ${attempt}/${attempts}" >&2 + set +e + "$@" + status=$? + set -e + echo "::endgroup::" >&2 + + if [ "${status}" -eq 0 ]; then + exit 0 + fi + + if [ "${attempt}" -eq "${attempts}" ]; then + echo "::error::${label} failed after ${attempts} attempts with exit code ${status}." >&2 + exit "${status}" + fi + + sleep_seconds=$((delay * attempt)) + echo "::warning::${label} failed with exit code ${status}; retrying in ${sleep_seconds}s." >&2 + sleep "${sleep_seconds}" +done diff --git a/.github/workflows/memos-local-plugin-post-merge-dry-run.yml b/.github/workflows/memos-local-plugin-post-merge-dry-run.yml new file mode 100644 index 000000000..2be36b13d --- /dev/null +++ b/.github/workflows/memos-local-plugin-post-merge-dry-run.yml @@ -0,0 +1,29 @@ +name: MemOS Local Plugin — Post-Merge Dry Run + +on: + push: + branches: + - main + paths: + - ".github/workflows/memos-local-plugin-publish.yml" + - ".github/workflows/memos-local-plugin-post-merge-dry-run.yml" + - ".github/scripts/draft-local-plugin-release-notes.mjs" + - ".github/scripts/draft-local-plugin-release-notes.test.mjs" + - ".github/scripts/retry.sh" + +permissions: + contents: write + pull-requests: write + +jobs: + dry-run: + if: ${{ github.repository == 'MemTensor/MemOS' }} + uses: ./.github/workflows/memos-local-plugin-publish.yml + with: + version: "2.0.10" + tag: "latest" + git_ref: "" + release_notes: "" + dry_run: true + recover_existing_npm_release: false + secrets: inherit diff --git a/.github/workflows/memos-local-plugin-publish.yml b/.github/workflows/memos-local-plugin-publish.yml index f7e0172a5..372e1e240 100644 --- a/.github/workflows/memos-local-plugin-publish.yml +++ b/.github/workflows/memos-local-plugin-publish.yml @@ -14,6 +14,51 @@ on: description: "Git ref to build from (branch, tag, or SHA). Leave blank to use the branch selected above." required: false default: "" + release_notes: + description: "Optional Markdown release notes. Leave blank to let the configured draft service use real git evidence before publish." + required: false + default: "" + dry_run: + description: "Draft release notes and build artifacts only. Skip npm publish, tag, GitHub Release, and release PR." + required: true + type: boolean + default: true + recover_existing_npm_release: + description: "Allow reconstructing a missing tag/Release for an npm version. Keep false for normal releases." + required: true + type: boolean + default: false + workflow_call: + inputs: + version: + description: "Version to publish or dry-run (e.g. 2.0.10 or 2.0.11-beta.1)" + required: true + type: string + tag: + description: "npm dist-tag (latest for production, beta/next/alpha for testing)" + required: false + type: string + default: "latest" + git_ref: + description: "Git ref to build from. Leave blank to use the caller ref." + required: false + type: string + default: "" + release_notes: + description: "Optional Markdown release notes. Leave blank to let the configured draft service use evidence." + required: false + type: string + default: "" + dry_run: + description: "Draft release notes and build artifacts only. Skip npm publish, tag, GitHub Release, and release PR." + required: false + type: boolean + default: true + recover_existing_npm_release: + description: "Allow reconstructing a missing tag/Release for an npm version. Keep false for normal releases." + required: false + type: boolean + default: false concurrency: group: memos-local-plugin-publish @@ -34,7 +79,7 @@ jobs: include: - os: macos-14 platform: darwin-arm64 - - os: macos-15 + - os: macos-15-intel platform: darwin-x64 - os: ubuntu-latest platform: linux-x64 @@ -45,24 +90,28 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ inputs.git_ref || github.ref }} + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 - name: Install dependencies - run: npm install + shell: bash + run: bash ../../.github/scripts/retry.sh --label "npm ci" -- npm ci - name: Rebuild for x64 under Rosetta (darwin-x64 only) if: matrix.platform == 'darwin-x64' - run: | - arch -x86_64 npm rebuild better-sqlite3 + shell: bash + run: bash ../../.github/scripts/retry.sh --label "npm rebuild better-sqlite3 under Rosetta" -- arch -x86_64 npm rebuild better-sqlite3 - name: Collect prebuild shell: bash run: | - mkdir -p prebuilds/${{ matrix.platform }} - cp node_modules/better-sqlite3/build/Release/better_sqlite3.node prebuilds/${{ matrix.platform }}/ + bash ../../.github/scripts/retry.sh --label "collect ${{ matrix.platform }} prebuild" -- bash -euo pipefail -c ' + mkdir -p prebuilds/${{ matrix.platform }} + cp node_modules/better-sqlite3/build/Release/better_sqlite3.node prebuilds/${{ matrix.platform }}/ + ' - name: Upload prebuild artifact uses: actions/upload-artifact@v4 @@ -77,6 +126,7 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ inputs.git_ref || github.ref }} + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -92,56 +142,382 @@ jobs: - name: Organize prebuilds run: | - cd prebuilds - for dir in prebuild-*; do - platform="${dir#prebuild-}" - mkdir -p "$platform" - mv "$dir/better_sqlite3.node" "$platform/" - rmdir "$dir" - done - echo "Prebuilds collected:" - find . -name "*.node" -exec ls -lh {} \; + bash ../../.github/scripts/retry.sh --label "organize prebuild artifacts" -- bash -euo pipefail -c ' + cd prebuilds + for dir in prebuild-*; do + platform="${dir#prebuild-}" + mkdir -p "$platform" + mv "$dir/better_sqlite3.node" "$platform/" + rmdir "$dir" + done + echo "Prebuilds collected:" + find . -name "*.node" -exec ls -lh {} \; + ' - name: Install dependencies (skip native build) - run: npm install --ignore-scripts + run: bash ../../.github/scripts/retry.sh --label "npm ci --ignore-scripts" -- npm ci --ignore-scripts + + - name: Install Linux sqlite binding for validation + run: | + bash ../../.github/scripts/retry.sh --label "install linux sqlite binding for validation" -- bash -euo pipefail -c ' + test -s prebuilds/linux-x64/better_sqlite3.node + mkdir -p node_modules/better-sqlite3/build/Release + cp prebuilds/linux-x64/better_sqlite3.node node_modules/better-sqlite3/build/Release/better_sqlite3.node + ' - name: Generate telemetry credentials - run: node scripts/generate-telemetry-credentials.cjs + run: bash ../../.github/scripts/retry.sh --label "generate telemetry credentials" -- node scripts/generate-telemetry-credentials.cjs env: MEMOS_ARMS_ENDPOINT: ${{ secrets.MEMOS_ARMS_ENDPOINT }} MEMOS_ARMS_PID: ${{ secrets.MEMOS_ARMS_PID }} MEMOS_ARMS_ENV: ${{ secrets.MEMOS_ARMS_ENV }} - name: Bump version - run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + if [[ "${RELEASE_VERSION}" == v* ]]; then + echo "::error::version must not include a leading v." + exit 1 + fi + bash ../../.github/scripts/retry.sh --label "bump package version" -- npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + + - name: Validate and build package + run: | + bash ../../.github/scripts/retry.sh --attempts 2 --label "npm run lint" -- npm run lint + bash ../../.github/scripts/retry.sh --attempts 2 --label "npm test" -- npm test + bash ../../.github/scripts/retry.sh --label "npm pack dry-run" -- bash -euo pipefail -c 'npm pack --dry-run --json > "${RUNNER_TEMP}/memos-local-plugin-pack.json"' + + - name: Draft GitHub Release notes + id: release_notes + working-directory: . + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: memos-local-plugin-v${{ inputs.version }} + MANUAL_RELEASE_NOTES: ${{ inputs.release_notes }} + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} + DOC_AGENT_RELEASE_FAILURE_URL: ${{ secrets.DOC_AGENT_RELEASE_FAILURE_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} + run: bash .github/scripts/retry.sh --attempts 2 --label "draft GitHub Release notes" -- node .github/scripts/draft-local-plugin-release-notes.mjs + + - name: Prepare release notes inspection artifact + working-directory: . + env: + DRY_RUN: ${{ inputs.dry_run }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_NOTES_FILE: ${{ steps.release_notes.outputs.release_notes_file }} + EVIDENCE_FILE: ${{ steps.release_notes.outputs.evidence_file }} + DRAFT_FILE: ${{ steps.release_notes.outputs.draft_file }} + DRAFT_USED: ${{ steps.release_notes.outputs.draft_used }} + PREVIOUS_TAG: ${{ steps.release_notes.outputs.previous_tag }} + CURRENT_TAG: ${{ steps.release_notes.outputs.current_tag }} + CURRENT_REF: ${{ steps.release_notes.outputs.current_ref }} + DRAFT_CONFIDENCE: ${{ steps.release_notes.outputs.draft_confidence }} + MISSING_REQUIRED_COUNT: ${{ steps.release_notes.outputs.missing_required_count }} + VALIDATION_ATTEMPT_COUNT: ${{ steps.release_notes.outputs.validation_attempt_count }} + REPAIR_ATTEMPT_COUNT: ${{ steps.release_notes.outputs.repair_attempt_count }} + run: | + set -euo pipefail + inspection_dir="${RUNNER_TEMP}/memos-local-plugin-release-notes-inspection" + mkdir -p "${inspection_dir}" + + if [ -z "${RELEASE_NOTES_FILE}" ] || [ ! -s "${RELEASE_NOTES_FILE}" ]; then + echo "::error::Release notes file was not generated." + exit 1 + fi + + cp "${RELEASE_NOTES_FILE}" "${inspection_dir}/release-notes.md" + cp "${RUNNER_TEMP}/memos-local-plugin-pack.json" "${inspection_dir}/npm-pack.json" + if [ -n "${EVIDENCE_FILE}" ] && [ -s "${EVIDENCE_FILE}" ]; then + cp "${EVIDENCE_FILE}" "${inspection_dir}/evidence.json" + fi + if [ "${DRAFT_USED:-}" = "true" ]; then + if [ -z "${DRAFT_FILE}" ] || [ ! -s "${DRAFT_FILE}" ]; then + echo "::error::Draft JSON file was not generated." + exit 1 + fi + cp "${DRAFT_FILE}" "${inspection_dir}/release-notes-draft.json" + elif [ -n "${DRAFT_FILE}" ] && [ -s "${DRAFT_FILE}" ]; then + cp "${DRAFT_FILE}" "${inspection_dir}/release-notes-draft.json" + fi + + { + echo "# MemOS local plugin release notes inspection" + echo + echo "- dry_run: ${DRY_RUN}" + echo "- version: ${RELEASE_VERSION}" + echo "- draft_used: ${DRAFT_USED:-unknown}" + echo "- previous_tag: ${PREVIOUS_TAG:-n/a}" + echo "- current_tag: ${CURRENT_TAG:-n/a}" + echo "- current_ref: ${CURRENT_REF:-n/a}" + echo "- draft_confidence: ${DRAFT_CONFIDENCE:-n/a}" + echo "- missing_required_count: ${MISSING_REQUIRED_COUNT:-n/a}" + echo "- validation_attempt_count: ${VALIDATION_ATTEMPT_COUNT:-n/a}" + echo "- repair_attempt_count: ${REPAIR_ATTEMPT_COUNT:-n/a}" + echo + echo "Files:" + echo + echo "- release-notes.md" + echo "- npm-pack.json" + if [ -n "${EVIDENCE_FILE}" ] && [ -s "${EVIDENCE_FILE}" ]; then + echo "- evidence.json (redacted; no full diff or prompt guidance)" + fi + if [ -f "${inspection_dir}/release-notes-draft.json" ]; then + echo "- release-notes-draft.json" + fi + } > "${inspection_dir}/README.md" + + { + echo "### MemOS local plugin release notes" + echo + echo "- dry_run: \`${DRY_RUN}\`" + echo "- draft_used: \`${DRAFT_USED:-unknown}\`" + echo "- previous_tag: \`${PREVIOUS_TAG:-n/a}\`" + echo "- current_tag: \`${CURRENT_TAG:-n/a}\`" + echo "- current_ref: \`${CURRENT_REF:-n/a}\`" + echo "- draft_confidence: \`${DRAFT_CONFIDENCE:-n/a}\`" + echo "- missing_required_count: \`${MISSING_REQUIRED_COUNT:-n/a}\`" + echo "- validation_attempt_count: \`${VALIDATION_ATTEMPT_COUNT:-n/a}\`" + echo "- repair_attempt_count: \`${REPAIR_ATTEMPT_COUNT:-n/a}\`" + echo + echo "Download the workflow artifact \`memos-local-plugin-release-notes-inspection\` to review release-notes.md, release-notes-draft.json, and redacted evidence.json." + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload release notes inspection artifact + uses: actions/upload-artifact@v4 + with: + name: memos-local-plugin-release-notes-inspection + path: ${{ runner.temp }}/memos-local-plugin-release-notes-inspection + if-no-files-found: error + + - name: Stop before publish in dry run + if: ${{ inputs.dry_run == true }} + run: | + echo "::notice::dry_run=true; skipped npm publish, tag, GitHub Release, and release PR." - name: Publish to npm + if: ${{ inputs.dry_run != true }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} PACKAGE_NAME: "@memtensor/memos-local-plugin" RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: memos-local-plugin-v${{ inputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} + RECOVER_EXISTING_NPM_RELEASE: ${{ inputs.recover_existing_npm_release }} + DOC_AGENT_RELEASE_FAILURE_URL: ${{ secrets.DOC_AGENT_RELEASE_FAILURE_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} run: | - if npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then - echo "${PACKAGE_NAME}@${RELEASE_VERSION} already exists on npm; skipping publish." + set -euo pipefail + npm_view_log="${RUNNER_TEMP}/memos-local-plugin-npm-view.log" + npm_version_exists() { + for attempt in 1 2 3; do + set +e + npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >"${npm_view_log}" 2>&1 + status=$? + set -e + if [ "${status}" = 0 ]; then + sed -n '1,40p' "${npm_view_log}" + return 0 + fi + if grep -Eiq "E404|404 Not Found|No match found|is not in this registry" "${npm_view_log}"; then + return 1 + fi + sed -n '1,120p' "${npm_view_log}" + if [ "${attempt}" = 3 ]; then + echo "::error::npm view failed after three attempts; refusing to guess whether ${PACKAGE_NAME}@${RELEASE_VERSION} exists." + exit "${status}" + fi + sleep "$((attempt * 5))" + done + } + remote_tag_exists() { + local release_tag="$1" + for attempt in 1 2 3; do + set +e + git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1 + status=$? + set -e + if [ "${status}" = 0 ]; then + return 0 + fi + if [ "${status}" = 2 ]; then + return 1 + fi + if [ "${attempt}" = 3 ]; then + echo "::error::Failed to check remote tag ${release_tag} after three attempts." + exit "${status}" + fi + sleep "$((attempt * 5))" + done + } + + if npm_version_exists; then + release_tag="memos-local-plugin-v${RELEASE_VERSION}" + if remote_tag_exists "${release_tag}"; then + echo "${PACKAGE_NAME}@${RELEASE_VERSION} and ${release_tag} already exist; treating this as an idempotent rerun." + elif [ "${RECOVER_EXISTING_NPM_RELEASE}" = "true" ]; then + echo "Recovery mode enabled; npm version exists, so publish is skipped." + else + echo "::error::npm version exists but ${release_tag} does not. Refusing to invent release metadata without explicit recovery mode." + exit 1 + fi else - npm publish --access public --tag "${NPM_DIST_TAG}" + attempt_dir="${RUNNER_TEMP}/memos-local-plugin-npm-publish-attempts" + mkdir -p "${attempt_dir}" + for attempt in 1 2 3; do + set +e + npm publish --access public --tag "${NPM_DIST_TAG}" >"${attempt_dir}/${attempt}.log" 2>&1 + publish_status=$? + set -e + sed -n '1,160p' "${attempt_dir}/${attempt}.log" + if [ "${publish_status}" = 0 ]; then + break + fi + if npm_version_exists; then + echo "Publish returned an error, but npm now contains the requested version." + break + fi + if [ "${attempt}" = 3 ]; then + RELEASE_FAILURE_PHASE=npm-publish \ + RELEASE_FAILURE_ATTEMPT_DIR="${attempt_dir}" \ + node ../../.github/scripts/draft-local-plugin-release-notes.mjs \ + || echo "::warning::Failed to send the exhausted-retry notification." + echo "::error::npm publish failed after three attempts." + exit 1 + fi + sleep "$((attempt * 5))" + done + if ! npm_version_exists; then + echo "::error::npm publish appeared to finish, but ${PACKAGE_NAME}@${RELEASE_VERSION} is still not visible." + exit 1 + fi fi - name: Create release tag and PR + if: ${{ inputs.dry_run != true }} working-directory: . env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} + RELEASE_NOTES_FILE: ${{ steps.release_notes.outputs.release_notes_file }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail + if [ -z "${RELEASE_NOTES_FILE}" ] || [ ! -s "${RELEASE_NOTES_FILE}" ]; then + echo "::error::Release notes file was not generated." + exit 1 + fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" release_tag="memos-local-plugin-v${RELEASE_VERSION}" release_branch="release/${release_tag}" release_title="release: @memtensor/memos-local-plugin v${RELEASE_VERSION}" + remote_branch_sha() { + local branch="$1" + local out="${RUNNER_TEMP}/memos-local-plugin-remote-branch.txt" + for attempt in 1 2 3; do + set +e + git ls-remote --heads origin "${branch}" >"${out}" 2>&1 + status=$? + set -e + if [ "${status}" = 0 ]; then + awk '{print $1}' "${out}" + return 0 + fi + sed -n '1,120p' "${out}" + if [ "${attempt}" = 3 ]; then + echo "::error::Failed to check remote branch ${branch} after three attempts." + exit "${status}" + fi + sleep "$((attempt * 5))" + done + } + remote_tag_exists() { + local tag="$1" + for attempt in 1 2 3; do + set +e + git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1 + status=$? + set -e + if [ "${status}" = 0 ]; then + return 0 + fi + if [ "${status}" = 2 ]; then + return 1 + fi + if [ "${attempt}" = 3 ]; then + echo "::error::Failed to check remote tag ${tag} after three attempts." + exit "${status}" + fi + sleep "$((attempt * 5))" + done + } + create_release_if_missing() { + release_flags=() + if [[ "${RELEASE_VERSION}" == *-* || "${NPM_DIST_TAG}" != "latest" ]]; then + release_flags+=(--prerelease) + echo "Marking GitHub Release ${release_tag} as prerelease because version=${RELEASE_VERSION}, npm_dist_tag=${NPM_DIST_TAG}." + fi + for attempt in 1 2 3; do + if gh release view "${release_tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "GitHub Release ${release_tag} already exists; leaving it unchanged." + return 0 + fi + set +e + gh release create "${release_tag}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "$(git rev-parse HEAD)" \ + --title "OpenClaw Local Plugin v${RELEASE_VERSION}" \ + --notes-file "${RELEASE_NOTES_FILE}" \ + "${release_flags[@]}" + status=$? + set -e + if [ "${status}" = 0 ]; then + return 0 + fi + if [ "${attempt}" = 3 ]; then + if gh release view "${release_tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "GitHub Release ${release_tag} exists after a failed create response; treating as success." + return 0 + fi + echo "::error::Failed to create GitHub Release ${release_tag} after three attempts." + exit "${status}" + fi + sleep "$((attempt * 5))" + done + } + create_pr_if_missing() { + if gh pr view "${release_branch}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "Release PR already exists for ${release_branch}." + return 0 + fi + for attempt in 1 2 3; do + set +e + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${DEFAULT_BRANCH}" \ + --head "${release_branch}" \ + --title "${release_title}" \ + --body "Bumps apps/memos-local-plugin/package.json for the published npm release ${RELEASE_VERSION}." + status=$? + set -e + if [ "${status}" = 0 ]; then + return 0 + fi + if gh pr view "${release_branch}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "Release PR exists after a failed create response; treating as success." + return 0 + fi + if [ "${attempt}" = 3 ]; then + echo "::warning::Failed to create release PR automatically after three attempts. Open a PR from ${release_branch} to ${DEFAULT_BRANCH}." + return 0 + fi + sleep "$((attempt * 5))" + done + } git add apps/memos-local-plugin/package.json apps/memos-local-plugin/package-lock.json created_release_commit=false @@ -151,33 +527,29 @@ jobs: fi if [ "${created_release_commit}" = "true" ]; then - remote_branch_sha="$(git ls-remote --heads origin "${release_branch}" | awk '{print $1}')" + remote_branch_sha="$(remote_branch_sha "${release_branch}")" if [ -n "${remote_branch_sha}" ]; then - git push --force-with-lease="refs/heads/${release_branch}:${remote_branch_sha}" origin "HEAD:refs/heads/${release_branch}" + if [ "${remote_branch_sha}" != "$(git rev-parse HEAD)" ]; then + echo "::error::Release branch ${release_branch} already exists at a different commit; refusing to overwrite it." + exit 1 + fi + echo "Release branch ${release_branch} already points at this commit." else - git push origin "HEAD:refs/heads/${release_branch}" + bash .github/scripts/retry.sh --label "push release branch" -- git push origin "HEAD:refs/heads/${release_branch}" fi fi - if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then + if remote_tag_exists "${release_tag}"; then echo "Tag ${release_tag} already exists on origin; leaving it unchanged." else git tag "${release_tag}" - git push origin "refs/tags/${release_tag}" + bash .github/scripts/retry.sh --label "push release tag" -- git push origin "refs/tags/${release_tag}" fi + create_release_if_missing + if [ "${created_release_commit}" = "true" ]; then - if gh pr view "${release_branch}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - echo "Release PR already exists for ${release_branch}." - else - gh pr create \ - --repo "${GITHUB_REPOSITORY}" \ - --base "${DEFAULT_BRANCH}" \ - --head "${release_branch}" \ - --title "${release_title}" \ - --body "Bumps apps/memos-local-plugin/package.json for the published npm release ${RELEASE_VERSION}." \ - || echo "::warning::Failed to create release PR automatically. Open a PR from ${release_branch} to ${DEFAULT_BRANCH}." - fi + create_pr_if_missing else echo "No version bump changes to open as a PR." fi diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 846d7e3aa..beb66f731 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -9,6 +9,7 @@ permissions: jobs: deploy: + if: startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest diff --git a/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts b/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts index 2c6d7c6a6..f4871bd35 100644 --- a/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts +++ b/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts @@ -344,6 +344,13 @@ function buildPipeline( ...baseCfg, algorithm: { ...baseCfg.algorithm, + // This suite verifies the complete V7 evolution chain. Lightweight + // memory is enabled by default in production and intentionally skips + // reward / L2 / L3 / skill, so disable it explicitly for this test. + lightweightMemory: { + ...baseCfg.algorithm.lightweightMemory, + enabled: false, + }, reward: { ...baseCfg.algorithm.reward, feedbackWindowSec: 0, diff --git a/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts b/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts index 0e8944644..94441a273 100644 --- a/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts +++ b/apps/memos-local-plugin/tests/unit/startup-recovery.test.ts @@ -15,20 +15,21 @@ function initBody(): string { return source.slice(start, end); } -function stripScheduledRecoveryCallbacks(body: string): string { +function stripBackgroundRecoveryCallback(body: string): string { return body.replace( - /scheduleStartupRecovery\([\s\S]*?\n \}\);/g, - "scheduleStartupRecovery();", + /startupRecoveryPromise = \(async \(\) => \{[\s\S]*?\n\s*\}\)\(\);/g, + "startupRecoveryPromise = ;", ); } describe("memory-core startup recovery", () => { it("does not block init on stale/dirty episode recovery", () => { - const synchronousInitBody = stripScheduledRecoveryCallbacks(initBody()); + const body = initBody(); + const synchronousInitBody = stripBackgroundRecoveryCallback(body); expect(synchronousInitBody).not.toContain("await recoverOpenEpisodesAsSessionEnd(stale)"); expect(synchronousInitBody).not.toContain("await recoverDirtyClosedEpisodes(dirtyClosed)"); - expect(initBody()).toContain("scheduleStartupRecovery(\"startup.open_recovery\""); - expect(initBody()).toContain("scheduleStartupRecovery(\"startup.dirty_closed_recovery\""); + expect(body).toContain("startupRecoveryPromise = (async () => {"); + expect(body).not.toContain("await startupRecoveryPromise"); }); }); diff --git a/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts b/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts index ba09d1628..c0e0eb215 100644 --- a/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/migrator.test.ts @@ -171,15 +171,21 @@ describe("storage/migrator", () => { const db = openDb({ filepath: dbPath, agent: "openclaw" }); try { runMigrations(db); + db.exec(` + INSERT INTO sessions (id, agent, started_at, last_seen_at) + VALUES ('session-1', 'openclaw', 1, 1); + INSERT INTO episodes (id, session_id, started_at) + VALUES ('episode-1', 'session-1', 1); + `); // Seed test rows: two with NULL share_scope, two with explicit values. db.exec(` INSERT INTO traces ( - id, session_id, ts, role, value, priority, embedding, share_scope + id, episode_id, session_id, ts, user_text, agent_text, turn_id, share_scope ) VALUES - ('t-null-a', 'session-1', 10, 'user', 0.0, 0.0, X'', NULL), - ('t-null-b', 'session-1', 20, 'assistant', 0.0, 0.0, X'', NULL), - ('t-private', 'session-1', 30, 'user', 0.0, 0.0, X'', 'private'), - ('t-public', 'session-1', 40, 'assistant', 0.0, 0.0, X'', 'public') + ('t-null-a', 'episode-1', 'session-1', 10, 'user a', 'agent a', 10, NULL), + ('t-null-b', 'episode-1', 'session-1', 20, 'user b', 'agent b', 20, NULL), + ('t-private', 'episode-1', 'session-1', 30, 'user c', 'agent c', 30, 'private'), + ('t-public', 'episode-1', 'session-1', 40, 'user d', 'agent d', 40, 'public') `); const rows = db .prepare( From 639355cab401f96fe51d74c256aabbcdce51620e Mon Sep 17 00:00:00 2001 From: bittergreen Date: Tue, 21 Jul 2026 15:48:01 +0800 Subject: [PATCH 02/33] fix: Adjust feedback update review flow & using independent --- src/memos/api/config.py | 36 +++++++++- src/memos/api/handlers/__init__.py | 2 + src/memos/api/handlers/component_init.py | 5 +- src/memos/api/handlers/config_builders.py | 10 +++ src/memos/mem_feedback/feedback.py | 23 ++++-- .../init_components_for_scheduler.py | 14 +++- src/memos/templates/mem_feedback_prompts.py | 22 +++--- tests/mem_feedback/test_feedback.py | 70 +++++++++++++++++++ 8 files changed, 160 insertions(+), 22 deletions(-) diff --git a/src/memos/api/config.py b/src/memos/api/config.py index 4d2029969..6788a9bda 100644 --- a/src/memos/api/config.py +++ b/src/memos/api/config.py @@ -261,12 +261,14 @@ class APIConfig: """Centralized configuration management for MemOS APIs.""" @staticmethod - def _preference_extractor_extra_body(model_name: str) -> dict[str, Any] | None: + def _qwen_flash_extra_body(model_name: str) -> dict[str, Any] | None: normalized_model = model_name.strip().lower() if normalized_model.startswith(("qwen3.5", "qwen3.6")): return {"enable_thinking": False} return None + _preference_extractor_extra_body = _qwen_flash_extra_body + @staticmethod def get_profile_memory_reserved_top_k() -> int: """Get profile-memory MMR reserve count. @@ -516,6 +518,38 @@ def get_preference_extractor_llm_config() -> dict[str, Any]: # Fallback to general_llm config (which itself falls back to OpenAI) return APIConfig.get_memreader_general_llm_config() + @staticmethod + def get_feedback_llm_config() -> dict[str, Any]: + """Get LLM configuration for feedback processing. + + Used for: feedback judgement, semantic add/update decisions, and update safety review. + + Fallback chain: FEEDBACK_MODEL -> general_llm -> memreader config. + """ + feedback_model = os.getenv("FEEDBACK_MODEL") + if feedback_model: + extra_body = APIConfig._qwen_flash_extra_body(feedback_model) + config = { + "model_name_or_path": feedback_model, + "temperature": 0.8, + "max_tokens": 8000, + "top_p": 0.95, + "top_k": 20, + "api_key": os.getenv("FEEDBACK_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY")), + "api_base": os.getenv( + "FEEDBACK_API_BASE", + os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), + ), + "remove_think_prefix": True, + } + if extra_body is not None: + config["extra_body"] = extra_body + return { + "backend": "openai", + "config": config, + } + return APIConfig.get_memreader_general_llm_config() + @staticmethod def get_activation_vllm_config() -> dict[str, Any]: """Get Ollama configuration.""" diff --git a/src/memos/api/handlers/__init__.py b/src/memos/api/handlers/__init__.py index bd4c9f4b0..e6001c32a 100644 --- a/src/memos/api/handlers/__init__.py +++ b/src/memos/api/handlers/__init__.py @@ -20,6 +20,7 @@ from memos.api.handlers.component_init import init_server from memos.api.handlers.config_builders import ( build_embedder_config, + build_feedback_llm_config, build_graph_db_config, build_internet_retriever_config, build_llm_config, @@ -39,6 +40,7 @@ __all__ = [ "add_handler", "build_embedder_config", + "build_feedback_llm_config", "build_graph_db_config", "build_internet_retriever_config", "build_llm_config", diff --git a/src/memos/api/handlers/component_init.py b/src/memos/api/handlers/component_init.py index b9c209e61..d4595a19f 100644 --- a/src/memos/api/handlers/component_init.py +++ b/src/memos/api/handlers/component_init.py @@ -13,6 +13,7 @@ from memos.api.handlers.config_builders import ( build_chat_llm_config, build_embedder_config, + build_feedback_llm_config, build_feedback_reranker_config, build_graph_db_config, build_internet_retriever_config, @@ -156,6 +157,7 @@ def init_server() -> dict[str, Any]: # Build component configurations graph_db_config = build_graph_db_config() llm_config = build_llm_config() + feedback_llm_config = build_feedback_llm_config() chat_llm_config = build_chat_llm_config() playground_chat_llm_config = build_chat_llm_config("PLAYGROUND_CHAT_MODEL_LIST") embedder_config = build_embedder_config() @@ -170,6 +172,7 @@ def init_server() -> dict[str, Any]: # Create component instances graph_db = GraphStoreFactory.from_config(graph_db_config) llm = LLMFactory.from_config(llm_config) + feedback_llm = LLMFactory.from_config(feedback_llm_config) chat_llms = ( _init_chat_llms(chat_llm_config) if os.getenv("ENABLE_CHAT_API", "false") == "true" @@ -260,7 +263,7 @@ def init_server() -> dict[str, Any]: # Initialize feedback server feedback_server = SimpleMemFeedback( - llm=llm, + llm=feedback_llm, embedder=embedder, graph_store=graph_db, memory_manager=memory_manager, diff --git a/src/memos/api/handlers/config_builders.py b/src/memos/api/handlers/config_builders.py index 0a083e284..38702f850 100644 --- a/src/memos/api/handlers/config_builders.py +++ b/src/memos/api/handlers/config_builders.py @@ -157,6 +157,16 @@ def build_feedback_reranker_config() -> dict[str, Any]: return RerankerConfigFactory.model_validate(APIConfig.get_feedback_reranker_config()) +def build_feedback_llm_config() -> dict[str, Any]: + """ + Build feedback LLM configuration. + + Returns: + Validated feedback LLM configuration dictionary + """ + return LLMConfigFactory.model_validate(APIConfig.get_feedback_llm_config()) + + def build_internet_retriever_config() -> dict[str, Any]: """ Build internet retriever configuration. diff --git a/src/memos/mem_feedback/feedback.py b/src/memos/mem_feedback/feedback.py index c99cda434..a3a811769 100644 --- a/src/memos/mem_feedback/feedback.py +++ b/src/memos/mem_feedback/feedback.py @@ -859,9 +859,11 @@ def correct_item(data): if not should_keep_update(data["text"], data["old_memory"]): logger.warning( - f"[0107 Feedback Core: correct_item] Due to the excessive proportion of changes, skip update: {data}" + "[0107 Feedback Core: correct_item] Due to the excessive proportion " + "of changes, downgrade update to add: %s", + data, ) - return None + return {"operation": "ADD", "_downgraded_from_update": True} # id dehallucination original_id = data["id"] @@ -892,11 +894,13 @@ def correct_item(data): add_texts = [] llm_operations = [] for item in dehalluded_operations: - if item["operation"].lower() == "add" and "text" in item and item["text"]: - if item["text"] in add_texts: + if item["operation"].lower() == "add": + add_text = item.get("text") + if add_text and add_text in add_texts: continue llm_operations.append(item) - add_texts.append(item["text"]) + if add_text: + add_texts.append(add_text) elif item["operation"].lower() == "update": llm_operations.append(item) logger.info( @@ -907,10 +911,15 @@ def correct_item(data): has_update = any(item.get("operation").lower() == "update" for item in llm_operations) if has_update: filtered_items = [ - item for item in llm_operations if item.get("operation").lower() == "add" + item + for item in llm_operations + if item.get("operation").lower() == "add" + and not item.get("_downgraded_from_update") ] update_items = [ - item for item in llm_operations if item.get("operation").lower() != "add" + item + for item in llm_operations + if item.get("operation").lower() != "add" or item.get("_downgraded_from_update") ] if filtered_items: logger.info( diff --git a/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py b/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py index 05a50d49c..97ae0c8f3 100644 --- a/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py +++ b/src/memos/mem_scheduler/general_modules/init_components_for_scheduler.py @@ -95,6 +95,16 @@ def build_llm_config() -> dict[str, Any]: ) +def build_feedback_llm_config() -> dict[str, Any]: + """ + Build feedback LLM configuration. + + Returns: + Validated feedback LLM configuration dictionary + """ + return LLMConfigFactory.model_validate(APIConfig.get_feedback_llm_config()) + + def build_chat_llm_config() -> list[dict[str, Any]]: """ Build chat LLM configuration. @@ -257,6 +267,7 @@ def init_components() -> dict[str, Any]: # Build component configurations graph_db_config = build_graph_db_config() llm_config = build_llm_config() + feedback_llm_config = build_feedback_llm_config() embedder_config = build_embedder_config() nli_client_config = build_nli_client_config() mem_reader_config = build_mem_reader_config() @@ -269,6 +280,7 @@ def init_components() -> dict[str, Any]: # Create component instances graph_db = GraphStoreFactory.from_config(graph_db_config) llm = LLMFactory.from_config(llm_config) + feedback_llm = LLMFactory.from_config(feedback_llm_config) embedder = EmbedderFactory.from_config(embedder_config) plugin_manager.discover() @@ -337,7 +349,7 @@ def init_components() -> dict[str, Any]: ) # Initialize feedback server feedback_server = SimpleMemFeedback( - llm=llm, + llm=feedback_llm, embedder=embedder, graph_store=graph_db, memory_manager=memory_manager, diff --git a/src/memos/templates/mem_feedback_prompts.py b/src/memos/templates/mem_feedback_prompts.py index dd30c4f92..5b5073231 100644 --- a/src/memos/templates/mem_feedback_prompts.py +++ b/src/memos/templates/mem_feedback_prompts.py @@ -679,6 +679,8 @@ **Batch Assessment Rules**: - Independently assess each entry in the list and record the evaluation results +- The output must include only `id`, `reason`, and `judgement` for each entry. +- Do not repeat or return the original `text` or `old_memory` fields. **Key Decision Rules**: 1. If the core entities of old and new texts are different → Set `judgement` to "INVALID" (completely invalid) @@ -690,8 +692,7 @@ "operations_judgement": [ {{ "id": "...", - "text": "...", - "old_memory": "...", + "reason": "Briefly explain why this UPDATE is approved or rejected.", "judgement": "INVALID" | "NONE" | "UPDATE_APPROVED" }}, ... @@ -722,14 +723,12 @@ "operations_judgement": [ {{ "id": "275a", - "text": "On December 22, 2025 at 6:58 AM UTC, the user mentioned that Mission Terra is from Germany.", - "old_memory": "On December 13, 2025 at 4:02 PM UTC, the user mentioned that Mission Terra is a French national.", + "reason": "Both memories describe Mission Terra's nationality, and the new statement directly corrects the previous one.", "judgement": "UPDATE_APPROVED" }}, {{ "id": "88a4", - "text": "On December 22, 2025 at 6:58 AM UTC, the user mentioned that Mission Terra is from Germany.", - "old_memory": "On December 22, 2025 at 6:52 AM UTC, the user confirmed that Gladys Liu is an Italian citizen.", + "reason": "The new statement is about Mission Terra, while the old memory is about Gladys Liu.", "judgement": "INVALID" }} ] @@ -757,6 +756,8 @@ **批量评估规则**: - 对列表中的每个条目独立评估,记录评估结果 +- 每个评估结果只允许返回`id`、`reason`、`judgement`。 +- 不要复述或返回原始`text`和`old_memory`字段。 **关键决策规则**: 1. 如果新旧文本核心实体不同 → `judgement`置为"INVALID"(完全无效) @@ -770,8 +771,7 @@ // 评估后的完整operations列表 {{ "id": "...", - "text": "...", - "old_memory": "...", + "reason": "简要说明该UPDATE通过或拒绝的原因", "judgement": "INVALID" | "NONE" | "UPDATE_APPROVED" }}, ... @@ -802,14 +802,12 @@ "operations_judgement": [ {{ "id": "275a", - "text": "2025年12月22日 UTC 时间6:58,用户提到Mission Terra 来自德国。", - "old_memory": "2025年12月13日 UTC 时间16:02,用户提及 Mission Terra 是法国国籍。", + "reason": "新旧记忆都描述Mission Terra的国籍,新事实直接修正了旧事实。", "judgement": "UPDATE_APPROVED" }}, {{ "id": "88a4", - "text": "2025年12月22日 UTC 时间6:58,用户提到Mission Terra 来自德国。", - "old_memory": "2025年12月22日 UTC 时间6:52,用户确认 Gladys Liu 是意大利公民。", + "reason": "新事实描述Mission Terra,而旧记忆描述Gladys Liu,核心实体不同。", "judgement": "INVALID" }} ] diff --git a/tests/mem_feedback/test_feedback.py b/tests/mem_feedback/test_feedback.py index 7e8d3b718..4c52a0590 100644 --- a/tests/mem_feedback/test_feedback.py +++ b/tests/mem_feedback/test_feedback.py @@ -1,6 +1,11 @@ from unittest.mock import Mock from memos.mem_feedback.feedback import MemFeedback +from memos.memories.textual.item import TextualMemoryItem +from memos.templates.mem_feedback_prompts import ( + OPERATION_UPDATE_JUDGEMENT, + OPERATION_UPDATE_JUDGEMENT_ZH, +) def test_process_feedback_runs_answer_and_core_workflows(): @@ -43,3 +48,68 @@ def test_process_feedback_runs_answer_and_core_workflows(): session_id="session-1", task_id="task-1", ) + + +def test_standard_operations_downgrades_large_update_to_add(): + feedback = MemFeedback.__new__(MemFeedback) + memory_id = "8583d7dd-28ba-422c-a9e7-0cd2ec90bc6c" + old_memory = "用户喜欢简洁的技术文档。" + new_memory = "完全不同的新事实,涉及商城订单售后调价风险、退款金额差异和业务确认事项。" + current_memories = [TextualMemoryItem(id=memory_id, memory=old_memory)] + + operations = feedback.standard_operations( + [ + { + "id": memory_id, + "text": new_memory, + "operation": "UPDATE", + "old_memory": old_memory, + } + ], + current_memories, + ) + + assert operations == [{"operation": "ADD", "_downgraded_from_update": True}] + + +def test_standard_operations_keeps_downgraded_add_when_other_updates_exist(): + feedback = MemFeedback.__new__(MemFeedback) + downgraded_id = "8583d7dd-28ba-422c-a9e7-0cd2ec90bc6c" + update_id = "35098647-1d67-4a29-aae8-134fefc2f6b0" + current_memories = [ + TextualMemoryItem(id=downgraded_id, memory="用户喜欢简洁的技术文档。"), + TextualMemoryItem(id=update_id, memory="用户在A公司工作。"), + ] + + operations = feedback.standard_operations( + [ + { + "id": downgraded_id, + "text": "完全不同的新事实,涉及商城订单售后调价风险、退款金额差异和业务确认事项。", + "operation": "UPDATE", + "old_memory": "用户喜欢简洁的技术文档。", + }, + { + "id": update_id, + "text": "用户在B公司工作。", + "operation": "UPDATE", + "old_memory": "用户在A公司工作。", + }, + ], + current_memories, + ) + + assert {"operation": "ADD", "_downgraded_from_update": True} in operations + assert any( + item.get("operation") == "UPDATE" and item.get("id") == update_id for item in operations + ) + + +def test_update_judgement_prompt_omits_memory_text_from_response_schema(): + for prompt in [OPERATION_UPDATE_JUDGEMENT, OPERATION_UPDATE_JUDGEMENT_ZH]: + output_section = prompt.split("Output Format")[-1].split("Example 1")[0] + output_section = output_section.split("输出格式")[-1].split("示例1")[0] + + assert '"reason"' in output_section + assert '"text"' not in output_section + assert '"old_memory"' not in output_section From 20c27ce0c373ca44debd0f4c8f829384014236fa Mon Sep 17 00:00:00 2001 From: bittergreen Date: Tue, 21 Jul 2026 17:45:18 +0800 Subject: [PATCH 03/33] refactor: unify LLM provider environment config # Conflicts: # examples/basic_modules/llm.py # src/memos/mem_reader/multi_modal_struct.py --- evaluation/.env-example | 2 +- evaluation/scripts/PrefEval/pref_eval.py | 2 +- evaluation/scripts/PrefEval/pref_mem0.py | 2 +- evaluation/scripts/PrefEval/pref_memobase.py | 2 +- evaluation/scripts/PrefEval/pref_memos.py | 2 +- evaluation/scripts/PrefEval/pref_memu.py | 2 +- .../scripts/PrefEval/pref_supermemory.py | 2 +- evaluation/scripts/PrefEval/pref_zep.py | 2 +- evaluation/scripts/locomo/locomo_eval.py | 2 +- evaluation/scripts/locomo/locomo_openai.py | 346 +++++++++--------- evaluation/scripts/longmemeval/lme_eval.py | 2 +- evaluation/scripts/longmemeval/lme_rag.py | 2 +- examples/basic_modules/llm.py | 69 ++-- .../core_memories/general_textual_memory.py | 5 +- .../core_memories/naive_textual_memory.py | 5 +- src/memos/api/config.py | 159 +++----- src/memos/configs/mem_scheduler.py | 22 +- src/memos/hello_world.py | 2 +- src/memos/mem_reader/multi_modal_struct.py | 26 +- .../mem_scheduler/analyzer/eval_analyzer.py | 6 +- tests/api/test_llm_provider_config.py | 121 ++++++ .../test_preference_extractor_llm_config.py | 18 +- tests/mem_scheduler/test_config.py | 19 +- .../test_openai_env_unification.py | 32 ++ tests/test_hello_world.py | 2 +- 25 files changed, 506 insertions(+), 348 deletions(-) create mode 100644 tests/api/test_llm_provider_config.py create mode 100644 tests/mem_scheduler/test_openai_env_unification.py diff --git a/evaluation/.env-example b/evaluation/.env-example index bab6f679e..cd3943fb6 100644 --- a/evaluation/.env-example +++ b/evaluation/.env-example @@ -1,7 +1,7 @@ # memory process model MODEL="gpt-4o-mini" OPENAI_API_KEY="sk-***REDACTED***" -OPENAI_BASE_URL="http://***.***.***.***:3000/v1" +OPENAI_API_BASE="http://***.***.***.***:3000/v1" # response model diff --git a/evaluation/scripts/PrefEval/pref_eval.py b/evaluation/scripts/PrefEval/pref_eval.py index ec079614d..9da3c9438 100644 --- a/evaluation/scripts/PrefEval/pref_eval.py +++ b/evaluation/scripts/PrefEval/pref_eval.py @@ -17,7 +17,7 @@ load_dotenv() API_KEY = os.getenv("OPENAI_API_KEY") -API_URL = os.getenv("OPENAI_BASE_URL") +API_URL = os.getenv("OPENAI_API_BASE") async def call_gpt4o_mini_async(client: OpenAI, prompt: str) -> str: diff --git a/evaluation/scripts/PrefEval/pref_mem0.py b/evaluation/scripts/PrefEval/pref_mem0.py index 300e0ede3..5b4fed9a5 100644 --- a/evaluation/scripts/PrefEval/pref_mem0.py +++ b/evaluation/scripts/PrefEval/pref_mem0.py @@ -22,7 +22,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") os.environ["MEM0_API_KEY"] = os.getenv("MEM0_API_KEY") diff --git a/evaluation/scripts/PrefEval/pref_memobase.py b/evaluation/scripts/PrefEval/pref_memobase.py index 776642657..03508efcf 100644 --- a/evaluation/scripts/PrefEval/pref_memobase.py +++ b/evaluation/scripts/PrefEval/pref_memobase.py @@ -22,7 +22,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_memos.py b/evaluation/scripts/PrefEval/pref_memos.py index bbe1788b5..05b41492a 100644 --- a/evaluation/scripts/PrefEval/pref_memos.py +++ b/evaluation/scripts/PrefEval/pref_memos.py @@ -23,7 +23,7 @@ load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_memu.py b/evaluation/scripts/PrefEval/pref_memu.py index 00c411eb7..cbeca0702 100644 --- a/evaluation/scripts/PrefEval/pref_memu.py +++ b/evaluation/scripts/PrefEval/pref_memu.py @@ -24,7 +24,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_supermemory.py b/evaluation/scripts/PrefEval/pref_supermemory.py index 7386bc462..dec7ba68c 100644 --- a/evaluation/scripts/PrefEval/pref_supermemory.py +++ b/evaluation/scripts/PrefEval/pref_supermemory.py @@ -22,7 +22,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/PrefEval/pref_zep.py b/evaluation/scripts/PrefEval/pref_zep.py index 8a4d50558..a664d6dec 100644 --- a/evaluation/scripts/PrefEval/pref_zep.py +++ b/evaluation/scripts/PrefEval/pref_zep.py @@ -24,7 +24,7 @@ sys.path.insert(0, EVAL_SCRIPTS_DIR) load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -BASE_URL = os.getenv("OPENAI_BASE_URL") +BASE_URL = os.getenv("OPENAI_API_BASE") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") tokenizer = tiktoken.get_encoding("cl100k_base") diff --git a/evaluation/scripts/locomo/locomo_eval.py b/evaluation/scripts/locomo/locomo_eval.py index 6e7dd4083..5a2777423 100644 --- a/evaluation/scripts/locomo/locomo_eval.py +++ b/evaluation/scripts/locomo/locomo_eval.py @@ -305,7 +305,7 @@ async def main(frame, version="default", options=None, num_runs=1, max_workers=4 load_dotenv() oai_client = AsyncOpenAI( - api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL") + api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE") ) with open(response_path) as file: diff --git a/evaluation/scripts/locomo/locomo_openai.py b/evaluation/scripts/locomo/locomo_openai.py index 0b6c52922..06bfb81b8 100644 --- a/evaluation/scripts/locomo/locomo_openai.py +++ b/evaluation/scripts/locomo/locomo_openai.py @@ -1,173 +1,173 @@ -import argparse -import json -import os -import time - -from collections import defaultdict -from multiprocessing.dummy import Pool - -from dotenv import load_dotenv -from openai import OpenAI -from tenacity import retry, stop_after_attempt, wait_random_exponential -from tqdm import tqdm - - -load_dotenv() - -# Retry policy constants -WAIT_MIN = 5 # minimum backoff delay in seconds -WAIT_MAX = 30 # maximum backoff delay in seconds -MAX_TRIES = 10 # maximum number of retry attempts - -WORKERS = 5 # number of parallel worker processes - -ANSWER_PROMPT = """ - You are an intelligent memory assistant tasked with retrieving accurate information from conversation memories. - - # CONTEXT: - You have access to memories from a conversation. These memories contain - timestamped information that may be relevant to answering the question. - - # INSTRUCTIONS: - 1. Carefully analyze all provided memories - 2. Pay special attention to the timestamps to determine the answer - 3. If the question asks about a specific event or fact, look for direct evidence in the memories - 4. If the memories contain contradictory information, prioritize the most recent memory - 5. If there is a question about time references (like "last year", "two months ago", etc.), - calculate the actual date based on the memory timestamp. For example, if a memory from - 4 May 2022 mentions "went to India last year," then the trip occurred in 2021. - 6. Always convert relative time references to specific dates, months, or years. For example, - convert "last year" to "2022" or "two months ago" to "March 2023" based on the memory - timestamp. Ignore the reference while answering the question. - 7. Focus only on the content of the memories. Do not confuse character - names mentioned in memories with the actual users who created those memories. - 8. The answer should be less than 5-6 words. - - # APPROACH (Think step by step): - 1. First, examine all memories that contain information related to the question - 2. Examine the timestamps and content of these memories carefully - 3. Look for explicit mentions of dates, times, locations, or events that answer the question - 4. If the answer requires calculation (e.g., converting relative time references), show your work - 5. Formulate a precise, concise answer based solely on the evidence in the memories - 6. Double-check that your answer directly addresses the question asked - 7. Ensure your final answer is specific and avoids vague time references - - Memories: - - {context} - - Question: {question} - Answer: - """ - - -class OpenAIPredict: - def __init__(self, model="gpt-4o-mini"): - self.model = model - self.openai_client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL") - ) - self.results = defaultdict(list) - - def search_memory(self, idx): - with open(f"openai_memory/{idx}.txt", encoding="utf-8") as file: - memories = file.read().strip().replace("\n\n", "\n") - - return memories, 0 - - def process_question(self, val, idx): - question = val.get("question", "") - answer = val.get("answer", "") - category = val.get("category", -1) - - response, search_memory_time, response_time, context = self.answer_question(idx, question) - - result = { - "question": question, - "answer": response, - "category": category, - "golden_answer": answer, - "search_context": context, - "response_duration_ms": response_time, - "search_duration_ms": search_memory_time, - } - - return result - - @retry( - wait=wait_random_exponential(min=WAIT_MIN, max=WAIT_MAX), - stop=stop_after_attempt(MAX_TRIES), - reraise=True, - ) - def answer_question(self, idx, question): - memories, search_memory_time = self.search_memory(idx) - - answer_prompt = ANSWER_PROMPT.format(context=memories, question=question) - - t1 = time.time() - response = self.openai_client.chat.completions.create( - model=self.model, - messages=[{"role": "system", "content": answer_prompt}], - temperature=0.0, - ) - t2 = time.time() - response_time = (t2 - t1) * 1000 - return response.choices[0].message.content, search_memory_time, response_time, memories - - def process_data_file(self, file_path, output_file_path): - with open(file_path, encoding="utf-8") as f: - data = json.load(f) - - # Function to process each conversation - def process_conversation(item): - idx, conversation = item - results_for_conversation = [] - - # Process each question in the conversation - for question_item in tqdm( - conversation["qa"], desc=f"Processing questions for conversation {idx}", leave=False - ): - if int(question_item.get("category", "")) == 5: - continue - result = self.process_question(question_item, idx) - results_for_conversation.append(result) - - return idx, results_for_conversation - - # Use multiprocessing to process the conversations in parallel - with Pool(processes=WORKERS) as pool: - results = list( - tqdm( - pool.imap(process_conversation, list(enumerate(data))), - total=len(data), - desc="Processing conversations", - ) - ) - - # Reorganize results and store them in self.results - for idx, results_for_conversation in results: - self.results[f"locomo_exp_user_{idx}"] = results_for_conversation - - # Save results to output file - with open(output_file_path, "w") as f: - json.dump(self.results, f, indent=4) - - -def main(version): - os.makedirs(f"results/locomo/openai-{version}/", exist_ok=True) - output_file_path = f"results/locomo/openai-{version}/openai_locomo_responses.json" - openai_predict = OpenAIPredict() - openai_predict.process_data_file("data/locomo/locomo10.json", output_file_path) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "--version", - type=str, - default="default", - help="Version identifier for loading results (e.g., 1010)", - ) - args = parser.parse_args() - version = args.version - main(version) +import argparse +import json +import os +import time + +from collections import defaultdict +from multiprocessing.dummy import Pool + +from dotenv import load_dotenv +from openai import OpenAI +from tenacity import retry, stop_after_attempt, wait_random_exponential +from tqdm import tqdm + + +load_dotenv() + +# Retry policy constants +WAIT_MIN = 5 # minimum backoff delay in seconds +WAIT_MAX = 30 # maximum backoff delay in seconds +MAX_TRIES = 10 # maximum number of retry attempts + +WORKERS = 5 # number of parallel worker processes + +ANSWER_PROMPT = """ + You are an intelligent memory assistant tasked with retrieving accurate information from conversation memories. + + # CONTEXT: + You have access to memories from a conversation. These memories contain + timestamped information that may be relevant to answering the question. + + # INSTRUCTIONS: + 1. Carefully analyze all provided memories + 2. Pay special attention to the timestamps to determine the answer + 3. If the question asks about a specific event or fact, look for direct evidence in the memories + 4. If the memories contain contradictory information, prioritize the most recent memory + 5. If there is a question about time references (like "last year", "two months ago", etc.), + calculate the actual date based on the memory timestamp. For example, if a memory from + 4 May 2022 mentions "went to India last year," then the trip occurred in 2021. + 6. Always convert relative time references to specific dates, months, or years. For example, + convert "last year" to "2022" or "two months ago" to "March 2023" based on the memory + timestamp. Ignore the reference while answering the question. + 7. Focus only on the content of the memories. Do not confuse character + names mentioned in memories with the actual users who created those memories. + 8. The answer should be less than 5-6 words. + + # APPROACH (Think step by step): + 1. First, examine all memories that contain information related to the question + 2. Examine the timestamps and content of these memories carefully + 3. Look for explicit mentions of dates, times, locations, or events that answer the question + 4. If the answer requires calculation (e.g., converting relative time references), show your work + 5. Formulate a precise, concise answer based solely on the evidence in the memories + 6. Double-check that your answer directly addresses the question asked + 7. Ensure your final answer is specific and avoids vague time references + + Memories: + + {context} + + Question: {question} + Answer: + """ + + +class OpenAIPredict: + def __init__(self, model="gpt-4o-mini"): + self.model = model + self.openai_client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE") + ) + self.results = defaultdict(list) + + def search_memory(self, idx): + with open(f"openai_memory/{idx}.txt", encoding="utf-8") as file: + memories = file.read().strip().replace("\n\n", "\n") + + return memories, 0 + + def process_question(self, val, idx): + question = val.get("question", "") + answer = val.get("answer", "") + category = val.get("category", -1) + + response, search_memory_time, response_time, context = self.answer_question(idx, question) + + result = { + "question": question, + "answer": response, + "category": category, + "golden_answer": answer, + "search_context": context, + "response_duration_ms": response_time, + "search_duration_ms": search_memory_time, + } + + return result + + @retry( + wait=wait_random_exponential(min=WAIT_MIN, max=WAIT_MAX), + stop=stop_after_attempt(MAX_TRIES), + reraise=True, + ) + def answer_question(self, idx, question): + memories, search_memory_time = self.search_memory(idx) + + answer_prompt = ANSWER_PROMPT.format(context=memories, question=question) + + t1 = time.time() + response = self.openai_client.chat.completions.create( + model=self.model, + messages=[{"role": "system", "content": answer_prompt}], + temperature=0.0, + ) + t2 = time.time() + response_time = (t2 - t1) * 1000 + return response.choices[0].message.content, search_memory_time, response_time, memories + + def process_data_file(self, file_path, output_file_path): + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + + # Function to process each conversation + def process_conversation(item): + idx, conversation = item + results_for_conversation = [] + + # Process each question in the conversation + for question_item in tqdm( + conversation["qa"], desc=f"Processing questions for conversation {idx}", leave=False + ): + if int(question_item.get("category", "")) == 5: + continue + result = self.process_question(question_item, idx) + results_for_conversation.append(result) + + return idx, results_for_conversation + + # Use multiprocessing to process the conversations in parallel + with Pool(processes=WORKERS) as pool: + results = list( + tqdm( + pool.imap(process_conversation, list(enumerate(data))), + total=len(data), + desc="Processing conversations", + ) + ) + + # Reorganize results and store them in self.results + for idx, results_for_conversation in results: + self.results[f"locomo_exp_user_{idx}"] = results_for_conversation + + # Save results to output file + with open(output_file_path, "w") as f: + json.dump(self.results, f, indent=4) + + +def main(version): + os.makedirs(f"results/locomo/openai-{version}/", exist_ok=True) + output_file_path = f"results/locomo/openai-{version}/openai_locomo_responses.json" + openai_predict = OpenAIPredict() + openai_predict.process_data_file("data/locomo/locomo10.json", output_file_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "--version", + type=str, + default="default", + help="Version identifier for loading results (e.g., 1010)", + ) + args = parser.parse_args() + version = args.version + main(version) diff --git a/evaluation/scripts/longmemeval/lme_eval.py b/evaluation/scripts/longmemeval/lme_eval.py index 20681ac2c..d85eec1a1 100644 --- a/evaluation/scripts/longmemeval/lme_eval.py +++ b/evaluation/scripts/longmemeval/lme_eval.py @@ -277,7 +277,7 @@ async def main(frame, version, nlp_options, num_runs=3, num_workers=5): print(f"Starting evaluation for {frame} version {version}...") load_dotenv() - oai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) + oai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE")) response_path = f"results/lme/{frame}-{version}/{frame}_lme_responses.json" judged_path = f"results/lme/{frame}-{version}/{frame}_lme_judged.json" diff --git a/evaluation/scripts/longmemeval/lme_rag.py b/evaluation/scripts/longmemeval/lme_rag.py index 523102e11..faccfeb7a 100644 --- a/evaluation/scripts/longmemeval/lme_rag.py +++ b/evaluation/scripts/longmemeval/lme_rag.py @@ -23,7 +23,7 @@ load_dotenv() -openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) +openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_API_BASE")) class RAGFullContext(RAGManager): diff --git a/examples/basic_modules/llm.py b/examples/basic_modules/llm.py index 3fd7352c7..7d05fb2de 100644 --- a/examples/basic_modules/llm.py +++ b/examples/basic_modules/llm.py @@ -1,3 +1,5 @@ +import os + from memos.configs.llm import LLMConfigFactory, OllamaLLMConfig from memos.llms.factory import LLMFactory from memos.llms.ollama import OllamaLLM @@ -51,36 +53,43 @@ # Scenario 3: Using LLMFactory with OpenAI Backend # Prerequisites: -# 1. You need a valid OpenAI API key to run this scenario. -# 2. Replace 'sk-xxxx' with your actual API key below. - - -config = LLMConfigFactory.model_validate( - { - "backend": "openai", - "config": { - "model_name_or_path": "gpt-4.1-nano", - "temperature": 0.8, - "max_tokens": 1024, - "top_p": 0.9, - "top_k": 50, - "api_key": "sk-xxxx", - "api_base": "https://api.openai.com/v1", - }, - } -) -llm = LLMFactory.from_config(config) -messages = [ - {"role": "user", "content": "Hello, who are you"}, -] -response = llm.generate(messages) -print("Scenario 3:", response) -print("==" * 20) - -print("Scenario 3:\n") -for chunk in llm.generate_stream(messages): - print(chunk, end="") -print("==" * 20) +# 1. export OPENAI_API_KEY="sk-..." (never commit real keys to git) +# 2. Optional: export OPENAI_API_BASE="https://api.openai.com/v1" or your compatible endpoint + +_openai_key = os.getenv("OPENAI_API_KEY") +_openai_base = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1") + +if _openai_key: + config = LLMConfigFactory.model_validate( + { + "backend": "openai", + "config": { + "model_name_or_path": "gpt-4.1-nano", + "temperature": 0.8, + "max_tokens": 1024, + "top_p": 0.9, + "top_k": 50, + "api_key": _openai_key, + "api_base": _openai_base.rstrip("/"), + }, + } + ) + llm = LLMFactory.from_config(config) + messages = [ + {"role": "user", "content": "Hello, who are you"}, + ] + response = llm.generate(messages) + print("Scenario 3:", response) + print("==" * 20) + + print("Scenario 3 (stream):\n") + for chunk in llm.generate_stream(messages): + print(chunk, end="") + print("==" * 20) +else: + print( + "Scenario 3 skipped: set OPENAI_API_KEY (and optionally OPENAI_API_BASE) to run OpenAI example." + ) # Scenario 4: Using LLMFactory with Huggingface Models diff --git a/examples/core_memories/general_textual_memory.py b/examples/core_memories/general_textual_memory.py index 007736a6e..b13e077c3 100644 --- a/examples/core_memories/general_textual_memory.py +++ b/examples/core_memories/general_textual_memory.py @@ -16,10 +16,7 @@ "config": { "model_name_or_path": "gpt-4o-mini", "api_key": os.environ.get("OPENAI_API_KEY"), - "api_base": os.environ.get( - "OPENAI_BASE_URL", - os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), + "api_base": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), "temperature": 0.0, "remove_think_prefix": True, "max_tokens": 8192, diff --git a/examples/core_memories/naive_textual_memory.py b/examples/core_memories/naive_textual_memory.py index 1e7901e0f..149d3efe0 100644 --- a/examples/core_memories/naive_textual_memory.py +++ b/examples/core_memories/naive_textual_memory.py @@ -15,10 +15,7 @@ "config": { "model_name_or_path": "gpt-4o-mini", "api_key": os.environ.get("OPENAI_API_KEY"), - "api_base": os.environ.get( - "OPENAI_BASE_URL", - os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), + "api_base": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), "temperature": 0.0, "remove_think_prefix": True, }, diff --git a/src/memos/api/config.py b/src/memos/api/config.py index 6788a9bda..b3e45dfb2 100644 --- a/src/memos/api/config.py +++ b/src/memos/api/config.py @@ -260,6 +260,14 @@ def _auth_headers(): class APIConfig: """Centralized configuration management for MemOS APIs.""" + _OPENAI_API_BASE = "https://api.openai.com/v1" + _QWEN_API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1" + + @staticmethod + def _is_qwen_model(model_name: str) -> bool: + normalized_model = model_name.strip().lower() + return normalized_model.startswith("qwen") or "/qwen" in normalized_model + @staticmethod def _qwen_flash_extra_body(model_name: str) -> dict[str, Any] | None: normalized_model = model_name.strip().lower() @@ -267,7 +275,40 @@ def _qwen_flash_extra_body(model_name: str) -> dict[str, Any] | None: return {"enable_thinking": False} return None - _preference_extractor_extra_body = _qwen_flash_extra_body + @staticmethod + def _build_provider_llm_config( + model_name: str, + *, + temperature: float = 0.6, + max_tokens: int = 8000, + top_p: float = 0.95, + top_k: int = 20, + remove_think_prefix: bool = True, + ) -> dict[str, Any]: + """Build task LLM config from model name plus shared provider credentials. + + MEMRADER_MODEL remains a dedicated fine-tuned extractor. Other task-specific + model knobs choose only the model; endpoint credentials come from either + QWEN_* or OPENAI_* according to the model family. + """ + is_qwen = APIConfig._is_qwen_model(model_name) + config = { + "model_name_or_path": model_name, + "temperature": temperature, + "max_tokens": max_tokens, + "top_p": top_p, + "top_k": top_k, + "remove_think_prefix": remove_think_prefix, + "api_key": os.getenv("QWEN_API_KEY" if is_qwen else "OPENAI_API_KEY", "EMPTY"), + "api_base": os.getenv( + "QWEN_API_BASE" if is_qwen else "OPENAI_API_BASE", + APIConfig._QWEN_API_BASE if is_qwen else APIConfig._OPENAI_API_BASE, + ), + } + extra_body = APIConfig._qwen_flash_extra_body(model_name) if is_qwen else None + if extra_body is not None: + config["extra_body"] = extra_body + return {"backend": "qwen" if is_qwen else "openai", "config": config} @staticmethod def get_profile_memory_reserved_top_k() -> int: @@ -295,7 +336,7 @@ def get_openai_config() -> dict[str, Any]: "top_k": int(os.getenv("MOS_TOP_K", "50")), "remove_think_prefix": True, "api_key": os.getenv("OPENAI_API_KEY", "your-api-key-here"), - "api_base": os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), + "api_base": os.getenv("OPENAI_API_BASE", APIConfig._OPENAI_API_BASE), } @staticmethod @@ -381,15 +422,11 @@ def get_memreader_config() -> dict[str, Any]: general_model = os.getenv("MEMREADER_GENERAL_MODEL") enable_backup = os.getenv("MEMREADER_ENABLE_BACKUP", "false").lower() == "true" if general_model and enable_backup: + backup_config = APIConfig._build_provider_llm_config(general_model)["config"] config["backup_client"] = True config["backup_model_name_or_path"] = general_model - config["backup_api_key"] = os.getenv( - "MEMREADER_GENERAL_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ) - config["backup_api_base"] = os.getenv( - "MEMREADER_GENERAL_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ) + config["backup_api_key"] = backup_config["api_key"] + config["backup_api_base"] = backup_config["api_base"] return {"backend": "openai", "config": config} @@ -397,21 +434,13 @@ def get_memreader_config() -> dict[str, Any]: def get_qwen_llm_config() -> dict[str, Any] | None: if not os.getenv("QWEN_API_KEY"): return None - return { - "backend": "qwen", - "config": { - "model_name_or_path": os.getenv("QWEN_MODEL", "qwen-flash"), - "temperature": float(os.getenv("QWEN_TEMPERATURE", "0.8")), - "max_tokens": int(os.getenv("QWEN_MAX_TOKENS", "8000")), - "top_p": float(os.getenv("QWEN_TOP_P", "0.9")), - "top_k": int(os.getenv("QWEN_TOP_K", "50")), - "remove_think_prefix": os.getenv("QWEN_REMOVE_THINK_PREFIX", "true").lower() - == "true", - "api_key": os.getenv("QWEN_API_KEY", ""), - "api_base": os.getenv("QWEN_API_BASE", ""), - "model_schema": os.getenv("QWEN_MODEL_SCHEMA", "memos.configs.llm.QwenLLMConfig"), - }, - } + return APIConfig._build_provider_llm_config( + os.getenv("QWEN_MODEL", "qwen-flash"), + temperature=0.8, + max_tokens=8000, + top_p=0.9, + top_k=50, + ) @staticmethod def get_memreader_general_llm_config() -> dict[str, Any]: @@ -430,24 +459,7 @@ def get_memreader_general_llm_config() -> dict[str, Any]: # Check if specific general model is configured general_model = os.getenv("MEMREADER_GENERAL_MODEL") if general_model: - return { - "backend": os.getenv("MEMREADER_GENERAL_BACKEND", "openai"), - "config": { - "model_name_or_path": general_model, - "temperature": 0.6, - "max_tokens": int(os.getenv("MEMREADER_GENERAL_MAX_TOKENS", "8000")), - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv( - "MEMREADER_GENERAL_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ), - "api_base": os.getenv( - "MEMREADER_GENERAL_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - }, - } + return APIConfig._build_provider_llm_config(general_model) # Fallback to memreader config (same behavior as before for users who don't customize) return APIConfig.get_memreader_config() @@ -462,24 +474,7 @@ def get_image_parser_llm_config() -> dict[str, Any]: """ image_model = os.getenv("IMAGE_PARSER_MODEL") if image_model: - return { - "backend": os.getenv("IMAGE_PARSER_BACKEND", "openai"), - "config": { - "model_name_or_path": image_model, - "temperature": 0.6, - "max_tokens": int(os.getenv("IMAGE_PARSER_MAX_TOKENS", "4096")), - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv( - "IMAGE_PARSER_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ), - "api_base": os.getenv( - "IMAGE_PARSER_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - }, - } + return APIConfig._build_provider_llm_config(image_model, max_tokens=4096) # Fallback to general_llm config (which itself falls back to OpenAI) return APIConfig.get_memreader_general_llm_config() @@ -493,28 +488,7 @@ def get_preference_extractor_llm_config() -> dict[str, Any]: """ pref_model = os.getenv("PREFERENCE_EXTRACTOR_MODEL") if pref_model: - extra_body = APIConfig._preference_extractor_extra_body(pref_model) - config = { - "model_name_or_path": pref_model, - "temperature": 0.6, - "max_tokens": int(os.getenv("PREFERENCE_EXTRACTOR_MAX_TOKENS", "8000")), - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv( - "PREFERENCE_EXTRACTOR_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY") - ), - "api_base": os.getenv( - "PREFERENCE_EXTRACTOR_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - } - if extra_body is not None: - config["extra_body"] = extra_body - return { - "backend": os.getenv("PREFERENCE_EXTRACTOR_BACKEND", "openai"), - "config": config, - } + return APIConfig._build_provider_llm_config(pref_model) # Fallback to general_llm config (which itself falls back to OpenAI) return APIConfig.get_memreader_general_llm_config() @@ -528,26 +502,7 @@ def get_feedback_llm_config() -> dict[str, Any]: """ feedback_model = os.getenv("FEEDBACK_MODEL") if feedback_model: - extra_body = APIConfig._qwen_flash_extra_body(feedback_model) - config = { - "model_name_or_path": feedback_model, - "temperature": 0.8, - "max_tokens": 8000, - "top_p": 0.95, - "top_k": 20, - "api_key": os.getenv("FEEDBACK_API_KEY", os.getenv("OPENAI_API_KEY", "EMPTY")), - "api_base": os.getenv( - "FEEDBACK_API_BASE", - os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), - ), - "remove_think_prefix": True, - } - if extra_body is not None: - config["extra_body"] = extra_body - return { - "backend": "openai", - "config": config, - } + return APIConfig._build_provider_llm_config(feedback_model, temperature=0.8) return APIConfig.get_memreader_general_llm_config() @staticmethod diff --git a/src/memos/configs/mem_scheduler.py b/src/memos/configs/mem_scheduler.py index f76ddecc4..6314ef596 100644 --- a/src/memos/configs/mem_scheduler.py +++ b/src/memos/configs/mem_scheduler.py @@ -212,6 +212,14 @@ class OpenAIConfig(BaseConfig, DictConversionMixin, EnvConfigMixin): base_url: str = Field(default="", description="Base URL for API endpoint") default_model: str = Field(default="", description="Default model to use") + @classmethod + def from_env(cls) -> "OpenAIConfig": + return cls( + api_key=os.getenv("OPENAI_API_KEY", ""), + base_url=os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), + default_model=os.getenv("MEMSCHEDULER_OPENAI_DEFAULT_MODEL", "gpt-4o-mini"), + ) + class AuthConfig(BaseConfig, DictConversionMixin): rabbitmq: RabbitMQConfig | None = None @@ -339,10 +347,16 @@ def from_local_env(cls) -> "AuthConfig": except (ValueError, Exception) as e: logger.warning(f"Failed to initialize RabbitMQ config from environment: {e}") - # Try to initialize OpenAI config - check if any OpenAI env vars exist + # Try to initialize OpenAI config - shared credentials plus scheduler model override. try: - openai_prefix = OpenAIConfig.get_env_prefix() - has_openai_env = any(key.startswith(openai_prefix) for key in os.environ) + has_openai_env = any( + key in os.environ + for key in [ + "OPENAI_API_KEY", + "OPENAI_API_BASE", + "MEMSCHEDULER_OPENAI_DEFAULT_MODEL", + ] + ) if has_openai_env: openai_config = OpenAIConfig.from_env() logger.info("Successfully initialized OpenAI configuration") @@ -375,7 +389,7 @@ def set_openai_config_to_environment(self): # Set environment variables only if openai config is available if self.openai is not None: os.environ["OPENAI_API_KEY"] = self.openai.api_key - os.environ["OPENAI_BASE_URL"] = self.openai.base_url + os.environ["OPENAI_API_BASE"] = self.openai.base_url os.environ["MODEL"] = self.openai.default_model else: logger = logging.getLogger(__name__) diff --git a/src/memos/hello_world.py b/src/memos/hello_world.py index 924fc746f..069b28377 100644 --- a/src/memos/hello_world.py +++ b/src/memos/hello_world.py @@ -72,7 +72,7 @@ def memos_chentang_hello_world(user_id: str = "locomo_exp_user_1", version: str "temperature": 0, "max_tokens": 8192, "api_key": os.getenv("OPENAI_API_KEY"), - "api_base": os.getenv("OPENAI_BASE_URL"), + "api_base": os.getenv("OPENAI_API_BASE"), }, }, "vector_db": { diff --git a/src/memos/mem_reader/multi_modal_struct.py b/src/memos/mem_reader/multi_modal_struct.py index c962ff528..5815d39ec 100644 --- a/src/memos/mem_reader/multi_modal_struct.py +++ b/src/memos/mem_reader/multi_modal_struct.py @@ -1,5 +1,6 @@ import concurrent.futures import json +import os import re import traceback import uuid @@ -30,6 +31,12 @@ logger = log.get_logger(__name__) +_DEFAULT_MULTI_VIEW_EXTRACTOR_MODEL = "gpt-4.1-mini" + + +def _get_multi_view_extractor_model() -> str: + return os.getenv("MULTI_VIEW_EXTRACTOR_MODEL", _DEFAULT_MULTI_VIEW_EXTRACTOR_MODEL) + class MultiModalStructMemReader(SimpleStructMemReader): """Multimodal implementation of MemReader that inherits from @@ -61,6 +68,7 @@ def __init__(self, config: MultiModalStructMemReaderConfig): simple_config = SimpleStructMemReaderConfig(**config_dict) super().__init__(simple_config) + self.name = "MultiModalStructMemReader" self.memory_version_switch = getattr(config, "memory_version_switch", "off") # Image parser LLM (requires vision model) @@ -80,6 +88,22 @@ def __init__(self, config: MultiModalStructMemReaderConfig): direct_markdown_hostnames=direct_markdown_hostnames, ) + def _get_multi_view_extractor_llm(self): + """Return the OpenAI-backed LLM reserved for multi-view extraction.""" + cached_llm = getattr(self, "_multi_view_extractor_llm", None) + if cached_llm is not None: + return cached_llm + + from memos.api.config import APIConfig + from memos.configs.llm import LLMConfigFactory + from memos.llms.factory import LLMFactory + + config = LLMConfigFactory.model_validate( + APIConfig._build_provider_llm_config(_get_multi_view_extractor_model()) + ) + self._multi_view_extractor_llm = LLMFactory.from_config(config) + return self._multi_view_extractor_llm + def _embed_memory_items(self, items: list[TextualMemoryItem]) -> None: """Compute embeddings for a list of memory items in-place. @@ -377,7 +401,7 @@ def _build_window_from_items( if aggregated_file_ids: extra_kwargs["file_ids"] = aggregated_file_ids - # Propagate manager_user_id and project_id from constituent items + # Propagate manager_user_id, project_id, and related_id from constituent items for item in items: metadata = getattr(item, "metadata", None) if metadata is not None: diff --git a/src/memos/mem_scheduler/analyzer/eval_analyzer.py b/src/memos/mem_scheduler/analyzer/eval_analyzer.py index 49a382ce6..4125e333b 100644 --- a/src/memos/mem_scheduler/analyzer/eval_analyzer.py +++ b/src/memos/mem_scheduler/analyzer/eval_analyzer.py @@ -36,7 +36,7 @@ def __init__( self, openai_api_key: str | None = None, openai_base_url: str | None = None, - openai_model: str = "gpt-4o-mini", + openai_model: str | None = None, output_dir: str = "./tmp/eval_analyzer", ): """ @@ -53,8 +53,8 @@ def __init__( # Initialize OpenAI client self.openai_client = OpenAI( - api_key=openai_api_key or os.getenv("MEMSCHEDULER_OPENAI_API_KEY"), - base_url=openai_base_url or os.getenv("MEMSCHEDULER_OPENAI_BASE_URL"), + api_key=openai_api_key or os.getenv("OPENAI_API_KEY"), + base_url=openai_base_url or os.getenv("OPENAI_API_BASE"), ) self.openai_model = openai_model or os.getenv( "MEMSCHEDULER_OPENAI_DEFAULT_MODEL", "gpt-4o-mini" diff --git a/tests/api/test_llm_provider_config.py b/tests/api/test_llm_provider_config.py new file mode 100644 index 000000000..9cfbfc133 --- /dev/null +++ b/tests/api/test_llm_provider_config.py @@ -0,0 +1,121 @@ +from unittest.mock import MagicMock, patch + +from memos.api.config import APIConfig +from memos.mem_reader.multi_modal_struct import MultiModalStructMemReader + + +def test_task_qwen_model_uses_qwen_provider_env(monkeypatch): + monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "qwen3.6-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + config = APIConfig.get_preference_extractor_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen3.6-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + assert config["config"]["extra_body"] == {"enable_thinking": False} + + +def test_task_openai_model_uses_openai_provider_env(monkeypatch): + monkeypatch.setenv("IMAGE_PARSER_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + config = APIConfig.get_image_parser_llm_config() + + assert config["backend"] == "openai" + assert config["config"]["model_name_or_path"] == "gpt-4.1-mini" + assert config["config"]["api_key"] == "openai-key" + assert config["config"]["api_base"] == "https://openai.example/v1" + + +def test_qwen_llm_only_uses_model_and_provider_endpoint_env(monkeypatch): + monkeypatch.setenv("QWEN_MODEL", "qwen-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("QWEN_TEMPERATURE", "1.9") + monkeypatch.setenv("QWEN_MAX_TOKENS", "123") + monkeypatch.setenv("QWEN_TOP_P", "0.1") + monkeypatch.setenv("QWEN_TOP_K", "3") + monkeypatch.setenv("QWEN_REMOVE_THINK_PREFIX", "false") + + config = APIConfig.get_qwen_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + assert config["config"]["temperature"] == 0.8 + assert config["config"]["max_tokens"] == 8000 + assert config["config"]["top_p"] == 0.9 + assert config["config"]["top_k"] == 50 + assert config["config"]["remove_think_prefix"] is True + + +def test_feedback_model_ignores_task_scoped_endpoint_env(monkeypatch): + monkeypatch.setenv("FEEDBACK_MODEL", "qwen3.6-flash") + monkeypatch.setenv("FEEDBACK_API_KEY", "legacy-feedback-key") + monkeypatch.setenv("FEEDBACK_API_BASE", "https://legacy-feedback.example/v1") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + + config = APIConfig.get_feedback_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + + +def test_memreader_general_model_only_needs_model_name_and_provider_env(monkeypatch): + monkeypatch.setenv("MEMREADER_GENERAL_MODEL", "qwen-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.delenv("MEMREADER_GENERAL_API_KEY", raising=False) + monkeypatch.delenv("MEMREADER_GENERAL_API_BASE", raising=False) + + config = APIConfig.get_memreader_general_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + + +def test_memreader_backup_uses_provider_env_for_general_model(monkeypatch): + monkeypatch.setenv("MEMREADER_ENABLE_BACKUP", "true") + monkeypatch.setenv("MEMREADER_GENERAL_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + monkeypatch.setenv("MEMREADER_GENERAL_API_KEY", "legacy-general-key") + monkeypatch.setenv("MEMREADER_GENERAL_API_BASE", "https://legacy-general.example/v1") + + config = APIConfig.get_memreader_config() + + assert config["config"]["backup_client"] is True + assert config["config"]["backup_model_name_or_path"] == "gpt-4.1-mini" + assert config["config"]["backup_api_key"] == "openai-key" + assert config["config"]["backup_api_base"] == "https://openai.example/v1" + + +def test_multi_view_extractor_model_uses_provider_env(monkeypatch): + monkeypatch.setenv("MULTI_VIEW_EXTRACTOR_MODEL", "qwen-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + reader = MultiModalStructMemReader.__new__(MultiModalStructMemReader) + llm = MagicMock() + + with patch("memos.llms.factory.LLMFactory.from_config", return_value=llm) as from_config: + result = reader._get_multi_view_extractor_llm() + + config = from_config.call_args.args[0] + assert result is llm + assert config.backend == "qwen" + assert config.config.model_name_or_path == "qwen-flash" + assert config.config.api_key == "qwen-key" + assert config.config.api_base == "https://dashscope.example/v1" diff --git a/tests/mem_reader/test_preference_extractor_llm_config.py b/tests/mem_reader/test_preference_extractor_llm_config.py index b94acda7d..6eaca9114 100644 --- a/tests/mem_reader/test_preference_extractor_llm_config.py +++ b/tests/mem_reader/test_preference_extractor_llm_config.py @@ -16,35 +16,37 @@ def test_product_default_config_wires_preference_extractor_model(monkeypatch): monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "pref-model") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_BASE", "https://pref.example/v1") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_KEY", "pref-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") config = APIConfig.get_product_default_config()["mem_reader"]["config"] pref_config = config["preference_extractor_llm"] assert pref_config["backend"] == "openai" assert pref_config["config"]["model_name_or_path"] == "pref-model" - assert pref_config["config"]["api_base"] == "https://pref.example/v1" - assert pref_config["config"]["api_key"] == "pref-key" + assert pref_config["config"]["api_base"] == "https://openai.example/v1" + assert pref_config["config"]["api_key"] == "openai-key" def test_preference_extractor_qwen35_disables_thinking(monkeypatch): monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "qwen3.5-flash") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_BASE", "https://dashscope.example/v1") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_KEY", "pref-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") pref_config = APIConfig.get_preference_extractor_llm_config() + assert pref_config["backend"] == "qwen" assert pref_config["config"]["extra_body"] == {"enable_thinking": False} def test_preference_extractor_qwen36_disables_thinking(monkeypatch): monkeypatch.setenv("PREFERENCE_EXTRACTOR_MODEL", "qwen3.6-flash") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_BASE", "https://dashscope.example/v1") - monkeypatch.setenv("PREFERENCE_EXTRACTOR_API_KEY", "pref-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") pref_config = APIConfig.get_preference_extractor_llm_config() + assert pref_config["backend"] == "qwen" assert pref_config["config"]["extra_body"] == {"enable_thinking": False} diff --git a/tests/mem_scheduler/test_config.py b/tests/mem_scheduler/test_config.py index 729023490..287245ed1 100644 --- a/tests/mem_scheduler/test_config.py +++ b/tests/mem_scheduler/test_config.py @@ -97,7 +97,8 @@ def test_env_config_mixin_integration(self): """Test EnvConfigMixin integration with actual configuration classes""" # Set complete test environment variables test_env_vars = { - f"{ENV_PREFIX}OPENAI_API_KEY": "test-api-key-12345", + "OPENAI_API_KEY": "test-api-key-12345", + "OPENAI_API_BASE": "https://api.test.openai.com/v1", f"{ENV_PREFIX}OPENAI_DEFAULT_MODEL": "gpt-4", f"{ENV_PREFIX}RABBITMQ_HOST_NAME": "localhost", f"{ENV_PREFIX}RABBITMQ_PORT": "5672", @@ -122,6 +123,7 @@ def test_env_config_mixin_integration(self): # Test various configuration classes openai_config = OpenAIConfig.from_env() self.assertEqual(openai_config.api_key, "test-api-key-12345") + self.assertEqual(openai_config.base_url, "https://api.test.openai.com/v1") self.assertEqual(openai_config.default_model, "gpt-4") rabbitmq_config = RabbitMQConfig.from_env() @@ -145,6 +147,7 @@ class TestSchedulerConfig(unittest.TestCase): def setUp(self): self.env_backup = dict(os.environ) self._clear_prefixed_env_vars() + self._clear_unified_openai_env_vars() def tearDown(self): os.environ.clear() @@ -155,6 +158,10 @@ def _clear_prefixed_env_vars(self): if key.startswith(ENV_PREFIX): del os.environ[key] + def _clear_unified_openai_env_vars(self): + for key in ["OPENAI_API_KEY", "OPENAI_API_BASE"]: + os.environ.pop(key, None) + def test_loads_all_configs_from_env(self): """Test loading all configurations from prefixed environment variables""" os.environ.update( @@ -167,8 +174,8 @@ def test_loads_all_configs_from_env(self): f"{ENV_PREFIX}RABBITMQ_ERASE_ON_CONNECT": "false", f"{ENV_PREFIX}RABBITMQ_PORT": "5673", # OpenAI configs - f"{ENV_PREFIX}OPENAI_API_KEY": "test_api_key", - f"{ENV_PREFIX}OPENAI_BASE_URL": "https://api.test.openai.com", + "OPENAI_API_KEY": "test_api_key", + "OPENAI_API_BASE": "https://api.test.openai.com/v1", f"{ENV_PREFIX}OPENAI_DEFAULT_MODEL": "gpt-test", # GraphDBAuthConfig configs - NOTE THE CORRECT PREFIX! f"{ENV_PREFIX}GRAPHDBAUTH_URI": "bolt://test.db:7687", @@ -195,7 +202,7 @@ def test_uses_default_values_when_env_not_set(self): # RabbitMQ f"{ENV_PREFIX}RABBITMQ_HOST_NAME": "rabbit.test.com", # OpenAI - f"{ENV_PREFIX}OPENAI_API_KEY": "test_api_key", + "OPENAI_API_KEY": "test_api_key", # GraphDB - with correct prefix and valid password length f"{ENV_PREFIX}GRAPHDBAUTH_URI": "bolt://test.db:7687", f"{ENV_PREFIX}GRAPHDBAUTH_PASSWORD": "default_pass", # 11 chars (valid) @@ -242,7 +249,7 @@ def test_type_conversion(self): f"{ENV_PREFIX}RABBITMQ_PORT": "1234", f"{ENV_PREFIX}RABBITMQ_ERASE_ON_CONNECT": "yes", # OpenAI - f"{ENV_PREFIX}OPENAI_API_KEY": "test_api_key", + "OPENAI_API_KEY": "test_api_key", # GraphDB - correct prefix and valid password f"{ENV_PREFIX}GRAPHDBAUTH_URI": "bolt://test.db:7687", f"{ENV_PREFIX}GRAPHDBAUTH_PASSWORD": "type_conv_pass", # 13 chars (valid) @@ -279,7 +286,7 @@ def test_combined_with_local_config(self): os.environ.update( { f"{ENV_PREFIX}RABBITMQ_HOST_NAME": "env.rabbit.com", - f"{ENV_PREFIX}OPENAI_API_KEY": "env_api_key", + "OPENAI_API_KEY": "env_api_key", f"{ENV_PREFIX}GRAPHDBAUTH_USER": "env_user", f"{ENV_PREFIX}GRAPHDBAUTH_PASSWORD": "env_db_pass", # 11 chars (valid) } diff --git a/tests/mem_scheduler/test_openai_env_unification.py b/tests/mem_scheduler/test_openai_env_unification.py new file mode 100644 index 000000000..96711422c --- /dev/null +++ b/tests/mem_scheduler/test_openai_env_unification.py @@ -0,0 +1,32 @@ +from unittest.mock import patch + +from memos.mem_scheduler.analyzer.eval_analyzer import EvalAnalyzer + + +def test_eval_analyzer_uses_unified_openai_endpoint_env(monkeypatch, tmp_path): + monkeypatch.setenv("OPENAI_API_KEY", "unified-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://unified.example/v1") + legacy_prefix = "MEMSCHEDULER_OPENAI_" + monkeypatch.setenv(legacy_prefix + "API_KEY", "legacy-key") + monkeypatch.setenv(legacy_prefix + "BASE_URL", "https://legacy.example/v1") + monkeypatch.delenv("MEMSCHEDULER_OPENAI_DEFAULT_MODEL", raising=False) + + with patch("memos.mem_scheduler.analyzer.eval_analyzer.OpenAI") as openai: + analyzer = EvalAnalyzer(output_dir=str(tmp_path)) + + openai.assert_called_once_with( + api_key="unified-key", + base_url="https://unified.example/v1", + ) + assert analyzer.openai_model == "gpt-4o-mini" + + +def test_eval_analyzer_keeps_scheduler_default_model_override(monkeypatch, tmp_path): + monkeypatch.setenv("OPENAI_API_KEY", "unified-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://unified.example/v1") + monkeypatch.setenv("MEMSCHEDULER_OPENAI_DEFAULT_MODEL", "gpt-4.1-mini") + + with patch("memos.mem_scheduler.analyzer.eval_analyzer.OpenAI"): + analyzer = EvalAnalyzer(output_dir=str(tmp_path)) + + assert analyzer.openai_model == "gpt-4.1-mini" diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index e9c81c7f0..e72ff8cb6 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -127,7 +127,7 @@ def mock_getenv(key, default=None): mock_values = { "MODEL": "mock-model-name", "OPENAI_API_KEY": "mock-api-key", - "OPENAI_BASE_URL": "mock-api-url", + "OPENAI_API_BASE": "mock-api-url", "EMBEDDING_MODEL": "mock-embedding-model", } return mock_values.get(key, default) From 82b02a2e01838516ef9348b95906dfbcb152b88e Mon Sep 17 00:00:00 2001 From: bittergreen Date: Tue, 21 Jul 2026 19:34:51 +0800 Subject: [PATCH 04/33] fix: fix log for embedding models --- src/memos/embedders/universal_api.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/memos/embedders/universal_api.py b/src/memos/embedders/universal_api.py index c71ed6b5a..66ed4e62c 100644 --- a/src/memos/embedders/universal_api.py +++ b/src/memos/embedders/universal_api.py @@ -14,6 +14,19 @@ logger = get_logger(__name__) +def _embedding_log_extra_args(embedder: "UniversalAPIEmbedder", texts: list[str] | str) -> dict: + text_items = [texts] if isinstance(texts, str) else texts + return { + "model_name_or_path": getattr( + embedder.config, "model_name_or_path", "text-embedding-3-large" + ), + "backup_model_name_or_path": getattr(embedder.config, "backup_model_name_or_path", None), + "use_backup_client": getattr(embedder, "use_backup_client", False), + "text_len": len(text_items), + "text_content": text_items, + } + + def _sanitize_unicode(text: str) -> str: """ Remove Unicode surrogates and other problematic characters. @@ -60,11 +73,7 @@ def __init__(self, config: UniversalAPIEmbedderConfig): @timed_with_status( log_prefix="model_timed_embedding", - log_extra_args=lambda self, texts: { - "model_name_or_path": "text-embedding-3-large", - "text_len": len(texts), - "text_content": texts, - }, + log_extra_args=_embedding_log_extra_args, ) def embed(self, texts: list[str]) -> list[list[float]]: if isinstance(texts, str): From fc5f41e5f0857b3722c1c63aaddd029d06b5d573 Mon Sep 17 00:00:00 2001 From: dingliang <2650876010@qq.com> Date: Tue, 21 Jul 2026 19:42:07 +0800 Subject: [PATCH 05/33] feat(api):update SDK version number --- src/memos/api/client.py | 13 +++++++++++++ tests/api/test_client.py | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/memos/api/client.py b/src/memos/api/client.py index b8055b5b6..9c80ea71f 100644 --- a/src/memos/api/client.py +++ b/src/memos/api/client.py @@ -71,6 +71,18 @@ def _validate_profile_subject(self, user_id: str | None, agent_id: str | None) - if bool(user_id) == bool(agent_id): raise ValueError("exactly one of user_id or agent_id is required") + @staticmethod + def _normalize_task_status_response( + response_data: dict[str, Any], task_id: str + ) -> dict[str, Any]: + data = response_data.get("data") + if not (isinstance(data, list) and len(data) == 1 and isinstance(data[0], dict)): + return response_data + + normalized_data = data[0].copy() + normalized_data.setdefault("task_id", task_id) + return {**response_data, "data": normalized_data} + def _post_json_dict( self, endpoint: str, payload: dict[str, Any], operation: str ) -> dict[str, Any] | None: @@ -549,6 +561,7 @@ def get_task_status(self, task_id: str) -> MemOSGetTaskStatusResponse | None: ) response.raise_for_status() response_data = response.json() + response_data = self._normalize_task_status_response(response_data, task_id) return MemOSGetTaskStatusResponse(**response_data) except Exception as e: diff --git a/tests/api/test_client.py b/tests/api/test_client.py index 2e911e4f4..11a9f88b3 100644 --- a/tests/api/test_client.py +++ b/tests/api/test_client.py @@ -471,6 +471,32 @@ def test_task_status_response_parses_current_object_shape(client_module: Any) -> assert response.data.memory_views == {"added": 1} +def test_get_task_status_normalizes_legacy_list_response( + client: Any, client_module: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[dict[str, Any]] = [] + + def fake_post(url: str, **kwargs: Any) -> DummyResponse: + calls.append({"url": url, **kwargs}) + return DummyResponse( + { + "code": 200, + "message": "ok", + "data": [{"status": "completed"}], + } + ) + + monkeypatch.setattr(client_module.requests, "post", fake_post) + + response = client.get_task_status("task-legacy") + + assert response is not None + assert response.data.task_id == "task-legacy" + assert response.data.status == "completed" + assert response.data.memory_views is None + assert len(calls) == 1 + + def test_search_response_keeps_all_current_memory_view_lists(client_module: Any) -> None: response = client_module.MemOSSearchResponse( code=200, From d9e2f5dc2f6110e999bbf0cca95d5463d58707d5 Mon Sep 17 00:00:00 2001 From: bittergreen Date: Wed, 22 Jul 2026 11:00:21 +0800 Subject: [PATCH 06/33] fix: fix tests --- src/memos/hello_world.py | 2 +- src/memos/mem_reader/multi_modal_struct.py | 23 ---------------------- tests/api/test_llm_provider_config.py | 21 -------------------- tests/test_hello_world.py | 1 - 4 files changed, 1 insertion(+), 46 deletions(-) diff --git a/src/memos/hello_world.py b/src/memos/hello_world.py index 069b28377..ac5572cf2 100644 --- a/src/memos/hello_world.py +++ b/src/memos/hello_world.py @@ -72,7 +72,7 @@ def memos_chentang_hello_world(user_id: str = "locomo_exp_user_1", version: str "temperature": 0, "max_tokens": 8192, "api_key": os.getenv("OPENAI_API_KEY"), - "api_base": os.getenv("OPENAI_API_BASE"), + "api_base": os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), }, }, "vector_db": { diff --git a/src/memos/mem_reader/multi_modal_struct.py b/src/memos/mem_reader/multi_modal_struct.py index 5815d39ec..8a7bc2da1 100644 --- a/src/memos/mem_reader/multi_modal_struct.py +++ b/src/memos/mem_reader/multi_modal_struct.py @@ -1,6 +1,5 @@ import concurrent.futures import json -import os import re import traceback import uuid @@ -31,12 +30,6 @@ logger = log.get_logger(__name__) -_DEFAULT_MULTI_VIEW_EXTRACTOR_MODEL = "gpt-4.1-mini" - - -def _get_multi_view_extractor_model() -> str: - return os.getenv("MULTI_VIEW_EXTRACTOR_MODEL", _DEFAULT_MULTI_VIEW_EXTRACTOR_MODEL) - class MultiModalStructMemReader(SimpleStructMemReader): """Multimodal implementation of MemReader that inherits from @@ -88,22 +81,6 @@ def __init__(self, config: MultiModalStructMemReaderConfig): direct_markdown_hostnames=direct_markdown_hostnames, ) - def _get_multi_view_extractor_llm(self): - """Return the OpenAI-backed LLM reserved for multi-view extraction.""" - cached_llm = getattr(self, "_multi_view_extractor_llm", None) - if cached_llm is not None: - return cached_llm - - from memos.api.config import APIConfig - from memos.configs.llm import LLMConfigFactory - from memos.llms.factory import LLMFactory - - config = LLMConfigFactory.model_validate( - APIConfig._build_provider_llm_config(_get_multi_view_extractor_model()) - ) - self._multi_view_extractor_llm = LLMFactory.from_config(config) - return self._multi_view_extractor_llm - def _embed_memory_items(self, items: list[TextualMemoryItem]) -> None: """Compute embeddings for a list of memory items in-place. diff --git a/tests/api/test_llm_provider_config.py b/tests/api/test_llm_provider_config.py index 9cfbfc133..61292c438 100644 --- a/tests/api/test_llm_provider_config.py +++ b/tests/api/test_llm_provider_config.py @@ -1,7 +1,4 @@ -from unittest.mock import MagicMock, patch - from memos.api.config import APIConfig -from memos.mem_reader.multi_modal_struct import MultiModalStructMemReader def test_task_qwen_model_uses_qwen_provider_env(monkeypatch): @@ -101,21 +98,3 @@ def test_memreader_backup_uses_provider_env_for_general_model(monkeypatch): assert config["config"]["backup_model_name_or_path"] == "gpt-4.1-mini" assert config["config"]["backup_api_key"] == "openai-key" assert config["config"]["backup_api_base"] == "https://openai.example/v1" - - -def test_multi_view_extractor_model_uses_provider_env(monkeypatch): - monkeypatch.setenv("MULTI_VIEW_EXTRACTOR_MODEL", "qwen-flash") - monkeypatch.setenv("QWEN_API_KEY", "qwen-key") - monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") - reader = MultiModalStructMemReader.__new__(MultiModalStructMemReader) - llm = MagicMock() - - with patch("memos.llms.factory.LLMFactory.from_config", return_value=llm) as from_config: - result = reader._get_multi_view_extractor_llm() - - config = from_config.call_args.args[0] - assert result is llm - assert config.backend == "qwen" - assert config.config.model_name_or_path == "qwen-flash" - assert config.config.api_key == "qwen-key" - assert config.config.api_base == "https://dashscope.example/v1" diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index e72ff8cb6..44a1d02be 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -127,7 +127,6 @@ def mock_getenv(key, default=None): mock_values = { "MODEL": "mock-model-name", "OPENAI_API_KEY": "mock-api-key", - "OPENAI_API_BASE": "mock-api-url", "EMBEDDING_MODEL": "mock-embedding-model", } return mock_values.get(key, default) From a0e444b82f4c4873ca124fd9d2c7cc5dd9adb5e7 Mon Sep 17 00:00:00 2001 From: bittergreen Date: Wed, 22 Jul 2026 11:05:19 +0800 Subject: [PATCH 07/33] fix: preserve text when downgrading feedback updates --- src/memos/mem_feedback/feedback.py | 6 +++++- tests/mem_feedback/test_feedback.py | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/memos/mem_feedback/feedback.py b/src/memos/mem_feedback/feedback.py index a3a811769..0e65efa4b 100644 --- a/src/memos/mem_feedback/feedback.py +++ b/src/memos/mem_feedback/feedback.py @@ -863,7 +863,11 @@ def correct_item(data): "of changes, downgrade update to add: %s", data, ) - return {"operation": "ADD", "_downgraded_from_update": True} + return { + "operation": "ADD", + "text": data["text"], + "_downgraded_from_update": True, + } # id dehallucination original_id = data["id"] diff --git a/tests/mem_feedback/test_feedback.py b/tests/mem_feedback/test_feedback.py index 4c52a0590..a78025e16 100644 --- a/tests/mem_feedback/test_feedback.py +++ b/tests/mem_feedback/test_feedback.py @@ -69,7 +69,13 @@ def test_standard_operations_downgrades_large_update_to_add(): current_memories, ) - assert operations == [{"operation": "ADD", "_downgraded_from_update": True}] + assert operations == [ + { + "operation": "ADD", + "text": new_memory, + "_downgraded_from_update": True, + } + ] def test_standard_operations_keeps_downgraded_add_when_other_updates_exist(): @@ -99,7 +105,11 @@ def test_standard_operations_keeps_downgraded_add_when_other_updates_exist(): current_memories, ) - assert {"operation": "ADD", "_downgraded_from_update": True} in operations + assert { + "operation": "ADD", + "text": "完全不同的新事实,涉及商城订单售后调价风险、退款金额差异和业务确认事项。", + "_downgraded_from_update": True, + } in operations assert any( item.get("operation") == "UPDATE" and item.get("id") == update_id for item in operations ) From 5fddd672e4b296228aebbae445ddc999c48894a7 Mon Sep 17 00:00:00 2001 From: dingliang <2650876010@qq.com> Date: Wed, 22 Jul 2026 11:07:20 +0800 Subject: [PATCH 08/33] feat(api):update SDK version number --- pyproject.toml | 2 +- src/memos/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 00d240ba8..4208cd4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ ############################################################################## name = "MemoryOS" -version = "2.0.24" +version = "2.0.25" description = "Intelligence Begins with Memory" license = {text = "Apache-2.0"} readme = "README.md" diff --git a/src/memos/__init__.py b/src/memos/__init__.py index c42c7b8a5..1cfc03f4b 100644 --- a/src/memos/__init__.py +++ b/src/memos/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.0.24" +__version__ = "2.0.25" from memos.configs.mem_cube import GeneralMemCubeConfig from memos.configs.mem_os import MOSConfig From b686710c24f168791eefcabe330de7ac15542c82 Mon Sep 17 00:00:00 2001 From: bittergreen Date: Wed, 22 Jul 2026 11:20:53 +0800 Subject: [PATCH 09/33] fix: type parser factory config as base parser config --- src/memos/configs/parser.py | 27 +++++++++++++++++++++------ tests/configs/test_parser.py | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/memos/configs/parser.py b/src/memos/configs/parser.py index 22c1fe1b5..451954240 100644 --- a/src/memos/configs/parser.py +++ b/src/memos/configs/parser.py @@ -17,7 +17,7 @@ class ParserConfigFactory(BaseConfig): """Factory class for creating Parser configurations.""" backend: str = Field(..., description="Backend for parser") - config: dict[str, Any] = Field(..., description="Configuration for the parser backend") + config: BaseParserConfig = Field(..., description="Configuration for the parser backend") backend_to_class: ClassVar[dict[str, Any]] = { "markitdown": MarkItDownParserConfig, @@ -31,8 +31,23 @@ def validate_backend(cls, backend: str) -> str: raise ValueError(f"Invalid backend: {backend}") return backend - @model_validator(mode="after") - def create_config(self) -> "ParserConfigFactory": - config_class = self.backend_to_class[self.backend] - self.config = config_class(**self.config) - return self + @model_validator(mode="before") + @classmethod + def create_config(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + if "backend" not in data or "config" not in data: + return data + + config_class = cls.backend_to_class.get(data["backend"]) + if config_class is None: + return data + + config = data.get("config") + if isinstance(config, config_class): + return data + + data = data.copy() + data["config"] = config_class.model_validate(config) + return data diff --git a/tests/configs/test_parser.py b/tests/configs/test_parser.py index 2d4064af3..16c2fddf2 100644 --- a/tests/configs/test_parser.py +++ b/tests/configs/test_parser.py @@ -1,3 +1,5 @@ +import warnings + from memos.configs.parser import BaseParserConfig, MarkItDownParserConfig, ParserConfigFactory from tests.utils import ( check_config_base_class, @@ -52,3 +54,20 @@ def test_parser_config_factory(): ) check_config_instantiation_invalid(ParserConfigFactory) + + +def test_parser_config_factory_dump_without_serializer_warning(): + config = ParserConfigFactory.model_validate( + { + "backend": "markitdown", + "config": {}, + } + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + dumped = config.model_dump() + + assert dumped == {"backend": "markitdown", "config": {}} + assert isinstance(config.config, MarkItDownParserConfig) + assert not any("PydanticSerializationUnexpectedValue" in str(item.message) for item in caught) From 028612098f61ab4d2794378cd3f5a926f2dcbbbb Mon Sep 17 00:00:00 2001 From: bittergreen Date: Wed, 22 Jul 2026 11:41:46 +0800 Subject: [PATCH 10/33] fix: Open Code Review problems fix --- src/memos/hello_world.py | 2 +- src/memos/mem_feedback/feedback.py | 7 ++++--- tests/mem_feedback/test_feedback.py | 16 ++++++++++++++++ tests/test_hello_world.py | 24 ------------------------ 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/memos/hello_world.py b/src/memos/hello_world.py index ac5572cf2..7c1a9defc 100644 --- a/src/memos/hello_world.py +++ b/src/memos/hello_world.py @@ -72,7 +72,7 @@ def memos_chentang_hello_world(user_id: str = "locomo_exp_user_1", version: str "temperature": 0, "max_tokens": 8192, "api_key": os.getenv("OPENAI_API_KEY"), - "api_base": os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"), + "api_base": os.environ.get("OPENAI_API_BASE") or "https://api.openai.com/v1", }, }, "vector_db": { diff --git a/src/memos/mem_feedback/feedback.py b/src/memos/mem_feedback/feedback.py index 0e65efa4b..28f495afb 100644 --- a/src/memos/mem_feedback/feedback.py +++ b/src/memos/mem_feedback/feedback.py @@ -900,11 +900,12 @@ def correct_item(data): for item in dehalluded_operations: if item["operation"].lower() == "add": add_text = item.get("text") - if add_text and add_text in add_texts: + if not add_text: + continue + if add_text in add_texts: continue llm_operations.append(item) - if add_text: - add_texts.append(add_text) + add_texts.append(add_text) elif item["operation"].lower() == "update": llm_operations.append(item) logger.info( diff --git a/tests/mem_feedback/test_feedback.py b/tests/mem_feedback/test_feedback.py index a78025e16..bbf30ae94 100644 --- a/tests/mem_feedback/test_feedback.py +++ b/tests/mem_feedback/test_feedback.py @@ -115,6 +115,22 @@ def test_standard_operations_keeps_downgraded_add_when_other_updates_exist(): ) +def test_standard_operations_skips_add_without_text(): + feedback = MemFeedback.__new__(MemFeedback) + + operations = feedback.standard_operations( + [ + {"operation": "ADD"}, + {"operation": "ADD", "text": ""}, + {"operation": "ADD", "text": "用户喜欢简洁回答。"}, + {"operation": "ADD", "text": "用户喜欢简洁回答。"}, + ], + current_memories=[], + ) + + assert operations == [{"operation": "ADD", "text": "用户喜欢简洁回答。"}] + + def test_update_judgement_prompt_omits_memory_text_from_response_schema(): for prompt in [OPERATION_UPDATE_JUDGEMENT, OPERATION_UPDATE_JUDGEMENT_ZH]: output_section = prompt.split("Output Format")[-1].split("Example 1")[0] diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index 44a1d02be..5fb1963a4 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -2,7 +2,6 @@ from memos.hello_world import ( memos_chend_hello_world, - memos_chentang_hello_world, memos_dany_hello_world, memos_hello_world, memos_huojh_hello_world, @@ -115,26 +114,3 @@ def test_memos_yuqingchen_hello_world_logger_called(): assert result == "Hello world from memos-yuqingchen!" mock_logger.assert_called_once_with("memos_yuqingchen_hello_world function called.") - - -def test_memos_chen_tang_hello_world(): - import warnings - - from memos.memories.textual.general import GeneralTextMemory - - # Define return values for os.getenv - def mock_getenv(key, default=None): - mock_values = { - "MODEL": "mock-model-name", - "OPENAI_API_KEY": "mock-api-key", - "EMBEDDING_MODEL": "mock-embedding-model", - } - return mock_values.get(key, default) - - # Filter Pydantic serialization warnings - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=UserWarning, module="pydantic") - # Use patch to mock os.getenv - with patch("os.getenv", side_effect=mock_getenv): - memory = memos_chentang_hello_world() - assert isinstance(memory, GeneralTextMemory) From afd9d31a1f212224f0c65ce33abd26dae1c4a526 Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:56:22 +0200 Subject: [PATCH 11/33] feat: OpenRouter provider routing and reasoning control (#1958) * feat(memos): add OpenRouter provider routing * fix(memos): route OpenRouter prefs through all slots * Fix OpenRouter reasoning config types * fix(openrouter): harden routing detection * fix(openrouter): harden review follow-ups * fix(openrouter): preserve provider defaults --------- Co-authored-by: jiachengzhen Co-authored-by: shinetata <149466187+shinetata@users.noreply.github.com> --- .../src/ingest/providers/openai.ts | 2 + .../src/shared/llm-call.ts | 23 ++- .../src/shared/openrouter.ts | 34 +++ apps/memos-local-openclaw/src/types.ts | 6 + .../tests/llm-call.test.ts | 110 ++++++++++ .../core/config/defaults.ts | 12 ++ apps/memos-local-plugin/core/config/schema.ts | 43 ++++ .../core/embedding/providers/openai.ts | 5 +- .../core/embedding/types.ts | 6 + .../core/llm/providers/openai.ts | 18 ++ apps/memos-local-plugin/core/llm/types.ts | 12 ++ apps/memos-local-plugin/core/openrouter.ts | 34 +++ .../core/pipeline/memory-core.ts | 27 ++- .../docs/CONFIG-ADVANCED.md | 26 +++ .../tests/unit/config/load.test.ts | 73 +++++++ .../tests/unit/embedding/providers.test.ts | 38 ++++ .../tests/unit/llm/providers.test.ts | 195 ++++++++++++++++++ .../pipeline/bootstrap-llm-config.test.ts | 167 +++++++++++++++ 18 files changed, 818 insertions(+), 13 deletions(-) create mode 100644 apps/memos-local-openclaw/src/shared/openrouter.ts create mode 100644 apps/memos-local-openclaw/tests/llm-call.test.ts create mode 100644 apps/memos-local-plugin/core/openrouter.ts create mode 100644 apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts index 60f878e4c..8289f79ac 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts @@ -1,4 +1,5 @@ import type { SummarizerConfig, Logger } from "../../types"; +import { applyOpenRouterProviderRouting } from "../../shared/openrouter"; const SYSTEM_PROMPT = `You generate a retrieval-friendly title. @@ -592,5 +593,6 @@ function buildRequestBody(cfg: SummarizerConfig, body: Record): if (isZhipuEndpoint(endpoint)) { body.thinking = { type: "disabled" }; } + applyOpenRouterProviderRouting(cfg, body); return body; } diff --git a/apps/memos-local-openclaw/src/shared/llm-call.ts b/apps/memos-local-openclaw/src/shared/llm-call.ts index c2df76bd0..e28a26344 100644 --- a/apps/memos-local-openclaw/src/shared/llm-call.ts +++ b/apps/memos-local-openclaw/src/shared/llm-call.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import type { SummarizerConfig, SummaryProvider, Logger, PluginContext, OpenClawAPI } from "../types"; import { parseJsonOrJson5 } from "./json5"; +import { applyOpenRouterProviderRouting } from "./openrouter"; /** * Resolve a SecretInput (string | SecretRef) to a plain string. @@ -189,8 +190,8 @@ async function callLLMOnceAnthropic( }); if (!resp.ok) { - const body = await resp.text(); - throw new Error(`LLM call failed (${resp.status}): ${body}`); + const errorText = await resp.text(); + throw new Error(`LLM call failed (${resp.status}): ${errorText}`); } const json = (await resp.json()) as { content: Array<{ type: string; text: string }> }; @@ -211,22 +212,24 @@ async function callLLMOnceOpenAI( Authorization: `Bearer ${cfg.apiKey}`, ...cfg.headers, }; + const requestBody: Record = { + model, + temperature: opts.temperature ?? 0.1, + max_tokens: opts.maxTokens ?? 1024, + messages: [{ role: "user", content: prompt }], + }; + applyOpenRouterProviderRouting(cfg, requestBody); const resp = await fetch(endpoint, { method: "POST", headers, - body: JSON.stringify({ - model, - temperature: opts.temperature ?? 0.1, - max_tokens: opts.maxTokens ?? 1024, - messages: [{ role: "user", content: prompt }], - }), + body: JSON.stringify(requestBody), signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000), }); if (!resp.ok) { - const body = await resp.text(); - throw new Error(`LLM call failed (${resp.status}): ${body}`); + const errorText = await resp.text(); + throw new Error(`LLM call failed (${resp.status}): ${errorText}`); } const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> }; diff --git a/apps/memos-local-openclaw/src/shared/openrouter.ts b/apps/memos-local-openclaw/src/shared/openrouter.ts new file mode 100644 index 000000000..8ce34aa71 --- /dev/null +++ b/apps/memos-local-openclaw/src/shared/openrouter.ts @@ -0,0 +1,34 @@ +/** Shared OpenRouter request routing for OpenAI-compatible providers. */ + +export interface OpenRouterRoutingConfig { + endpoint?: string; + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter?: boolean; + providerIgnore?: string[]; + providerOrder?: string[]; +} + +const OPENROUTER_HOSTS = new Set(["openrouter.ai"]); + +export function applyOpenRouterProviderRouting( + config: OpenRouterRoutingConfig, + body: Record, +): boolean { + if (!isOpenRouter(config)) return false; + + const provider: Record = {}; + if (config.providerIgnore?.length) provider.ignore = config.providerIgnore; + if (config.providerOrder?.length) provider.order = config.providerOrder; + if (Object.keys(provider).length > 0) body.provider = provider; + return true; +} + +function isOpenRouter(config: OpenRouterRoutingConfig): boolean { + if (config.openRouter) return true; + if (!config.endpoint) return false; + try { + return OPENROUTER_HOSTS.has(new URL(config.endpoint).hostname.toLowerCase()); + } catch { + return false; + } +} diff --git a/apps/memos-local-openclaw/src/types.ts b/apps/memos-local-openclaw/src/types.ts index c7b847ff1..0eec78cbe 100644 --- a/apps/memos-local-openclaw/src/types.ts +++ b/apps/memos-local-openclaw/src/types.ts @@ -177,6 +177,12 @@ export interface ProviderConfig { headers?: Record; timeoutMs?: number; temperature?: number; + /** OpenRouter provider routing — providers to skip. */ + providerIgnore?: string[]; + /** OpenRouter provider routing — preferred order. */ + providerOrder?: string[]; + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter?: boolean; capabilities?: SharingCapabilities; } diff --git a/apps/memos-local-openclaw/tests/llm-call.test.ts b/apps/memos-local-openclaw/tests/llm-call.test.ts new file mode 100644 index 000000000..a599e5816 --- /dev/null +++ b/apps/memos-local-openclaw/tests/llm-call.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { callLLMOnce } from "../src/shared/llm-call"; +import { applyOpenRouterProviderRouting } from "../src/shared/openrouter"; +import type { SummarizerConfig } from "../src/types"; + +describe("shared/llm-call", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("reports whether OpenRouter provider routing was applied", () => { + const body: Record = {}; + + expect(applyOpenRouterProviderRouting({ + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["together"], + }, body)).toBe(true); + expect(body.provider).toEqual({ ignore: ["together"] }); + expect(applyOpenRouterProviderRouting({ endpoint: "https://api.openai.com/v1" }, {})).toBe(false); + }); + + it("adds OpenRouter provider preferences for OpenAI-compatible calls", async () => { + const cap: { url?: string; init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown, init?: unknown) => { + cap.url = String(url); + cap.init = init as RequestInit; + return new Response( + JSON.stringify({ choices: [{ message: { content: "ok" } }] }), + { status: 200 }, + ); + }), + ); + + const cfg: SummarizerConfig = { + provider: "openai_compatible", + endpoint: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + model: "google/gemini-test", + providerIgnore: ["together", "novita"], + providerOrder: ["google", "anthropic"], + }; + + const result = await callLLMOnce(cfg, "summarize this"); + + expect(result).toBe("ok"); + expect(cap.url).toBe("https://openrouter.ai/api/v1/chat/completions"); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["together", "novita"], + order: ["google", "anthropic"], + }); + }); + + it("allows an explicit OpenRouter opt-in for reverse proxies", async () => { + const cap: { init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: unknown, init?: unknown) => { + cap.init = init as RequestInit; + return new Response( + JSON.stringify({ choices: [{ message: { content: "ok" } }] }), + { status: 200 }, + ); + }), + ); + + const cfg = { + provider: "openai_compatible", + endpoint: "https://llm-proxy.example.com/v1", + apiKey: "sk-test", + model: "google/gemini-test", + providerIgnore: ["together"], + openRouter: true, + } as SummarizerConfig; + + await callLLMOnce(cfg, "summarize this"); + + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ ignore: ["together"] }); + }); + + it("omits provider preferences for non-OpenRouter OpenAI-compatible calls", async () => { + const cap: { init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: unknown, init?: unknown) => { + cap.init = init as RequestInit; + return new Response( + JSON.stringify({ choices: [{ message: { content: "ok" } }] }), + { status: 200 }, + ); + }), + ); + + const cfg: SummarizerConfig = { + provider: "openai_compatible", + endpoint: "https://api.openai.com/v1", + apiKey: "sk-test", + model: "gpt-test", + providerIgnore: ["together"], + providerOrder: ["google"], + }; + + await callLLMOnce(cfg, "summarize this"); + + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); +}); diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index b79df4a5b..36c6ef4b8 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -27,6 +27,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { endpoint: "", model: "Xenova/all-MiniLM-L6-v2", apiKey: "", + providerIgnore: [], + providerOrder: [], + openRouter: false, cache: { enabled: true, maxItems: 20_000, @@ -41,6 +44,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { apiKey: "", timeoutMs: 45_000, maxRetries: 3, + providerIgnore: [], + providerOrder: [], + openRouter: false, }, l3Llm: { // Empty by default — falls back to the shared `llm` settings. @@ -54,6 +60,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { apiKey: "", temperature: 0, timeoutMs: 60_000, + providerIgnore: [], + providerOrder: [], + openRouter: false, }, skillEvolver: { // Empty by default — falls back to the shared `llm` settings. @@ -65,6 +74,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { apiKey: "", temperature: 0, timeoutMs: 60_000, + providerIgnore: [], + providerOrder: [], + openRouter: false, }, storage: { ftsTokenizer: "trigram", diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index b698196d1..5a712a452 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -39,12 +39,38 @@ const EmbeddingSchema = Type.Object({ endpoint: StringWithDefault(""), model: StringWithDefault("Xenova/all-MiniLM-L6-v2"), apiKey: StringWithDefault(""), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: Type.Optional(Bool(false)), cache: Type.Object({ enabled: Bool(true), maxItems: NumberInRange(20_000, 0), }, { default: {} }), }, { default: {} }); +const ReasoningSchema = Type.Object({ + /** + * OpenRouter-compatible reasoning toggle. Omit the whole block to keep + * the provider/model default. + */ + enabled: Type.Optional(Type.Boolean()), + /** Optional provider effort hint for reasoning-capable models. */ + effort: Type.Optional(Type.Union([ + Type.Literal("minimal"), + Type.Literal("none"), + Type.Literal("low"), + Type.Literal("medium"), + Type.Literal("high"), + Type.Literal("xhigh"), + Type.Literal("max"), + ])), + /** Optional token budget for reasoning-capable providers. */ + maxTokens: Type.Optional(Type.Number({ minimum: 1 })), +}, { default: {} }); + const LlmSchema = Type.Object({ provider: Type.Union([ Type.Literal(""), @@ -65,6 +91,14 @@ const LlmSchema = Type.Object({ timeoutMs: NumberInRange(45_000, 1_000), /** Max retries on transient errors. */ maxRetries: NumberInRange(3, 0, 10), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: Type.Optional(Bool(false)), + /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ + reasoning: Type.Optional(ReasoningSchema), }, { default: {} }); /** @@ -89,6 +123,14 @@ const SkillEvolverSchema = Type.Object({ apiKey: StringWithDefault(""), temperature: NumberInRange(0, 0, 2), timeoutMs: NumberInRange(60_000, 1_000), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: Type.Optional(Bool(false)), + /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ + reasoning: Type.Optional(ReasoningSchema), }, { default: {} }); const StorageSchema = Type.Object({ @@ -601,4 +643,5 @@ export const ConfigSchema = Type.Object({ logging: LoggingSchema, }, { default: {} }); +export type ReasoningConfig = Static; export type ResolvedConfig = Static; diff --git a/apps/memos-local-plugin/core/embedding/providers/openai.ts b/apps/memos-local-plugin/core/embedding/providers/openai.ts index fd454ae29..c0df47831 100644 --- a/apps/memos-local-plugin/core/embedding/providers/openai.ts +++ b/apps/memos-local-plugin/core/embedding/providers/openai.ts @@ -10,6 +10,7 @@ */ import { ERROR_CODES, MemosError } from "../../../agent-contract/errors.js"; +import { applyOpenRouterProviderRouting } from "../../openrouter.js"; import { httpPostJson } from "../fetcher.js"; import type { EmbedRole, @@ -40,9 +41,11 @@ export class OpenAiEmbeddingProvider implements EmbeddingProvider { : "https://api.openai.com/v1/embeddings", ); const model = config.model && config.model.length > 0 ? config.model : "text-embedding-3-small"; + const body: Record = { input: texts, model }; + applyOpenRouterProviderRouting(config, body); const resp = await httpPostJson({ url, - body: { input: texts, model }, + body, headers: { Authorization: `Bearer ${config.apiKey}`, ...config.headers, diff --git a/apps/memos-local-plugin/core/embedding/types.ts b/apps/memos-local-plugin/core/embedding/types.ts index 5371b875e..4f3f5eb99 100644 --- a/apps/memos-local-plugin/core/embedding/types.ts +++ b/apps/memos-local-plugin/core/embedding/types.ts @@ -27,6 +27,12 @@ export interface EmbeddingConfig { model: string; dimensions: number; apiKey?: string; + /** OpenRouter provider routing — providers to skip. */ + providerIgnore?: string[]; + /** OpenRouter provider routing — preferred order. */ + providerOrder?: string[]; + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: boolean; cache: { enabled: boolean; maxItems: number; diff --git a/apps/memos-local-plugin/core/llm/providers/openai.ts b/apps/memos-local-plugin/core/llm/providers/openai.ts index 1801a5aad..d8a5a20af 100644 --- a/apps/memos-local-plugin/core/llm/providers/openai.ts +++ b/apps/memos-local-plugin/core/llm/providers/openai.ts @@ -6,12 +6,14 @@ */ import { ERROR_CODES, MemosError } from "../../../agent-contract/errors.js"; +import { applyOpenRouterProviderRouting } from "../../openrouter.js"; import { decodeSse, httpPostJson, httpPostStream } from "../fetcher.js"; import type { LlmMessage, LlmProvider, LlmProviderCtx, LlmProviderName, + ReasoningConfig, LlmStreamChunk, ProviderCallInput, ProviderCompletion, @@ -73,6 +75,10 @@ export class OpenAiLlmProvider implements LlmProvider { }; if (opts.jsonMode) body.response_format = { type: "json_object" }; if (opts.stop && opts.stop.length > 0) body.stop = opts.stop; + if (applyOpenRouterProviderRouting(config, body)) { + const reasoning = config.reasoning && serializeOpenRouterReasoning(config.reasoning); + if (reasoning) body.reasoning = reasoning; + } const headers: Record = {}; if (config.apiKey) { @@ -137,6 +143,10 @@ export class OpenAiLlmProvider implements LlmProvider { }; if (opts.jsonMode) body.response_format = { type: "json_object" }; if (opts.stop && opts.stop.length > 0) body.stop = opts.stop; + if (applyOpenRouterProviderRouting(config, body)) { + const reasoning = config.reasoning && serializeOpenRouterReasoning(config.reasoning); + if (reasoning) body.reasoning = reasoning; + } const headers: Record = {}; if (config.apiKey) { @@ -204,6 +214,14 @@ function normalizeEndpoint(url: string): string { return `${stripped}/chat/completions`; } +function serializeOpenRouterReasoning(reasoning: ReasoningConfig): Record | undefined { + const result: Record = {}; + if (reasoning.enabled !== undefined) result.enabled = reasoning.enabled; + if (reasoning.effort !== undefined) result.effort = reasoning.effort; + if (reasoning.maxTokens !== undefined) result.max_tokens = reasoning.maxTokens; + return Object.keys(result).length > 0 ? result : undefined; +} + function mapFinish(reason: string | undefined): ProviderCompletion["finishReason"] { switch (reason) { case "stop": diff --git a/apps/memos-local-plugin/core/llm/types.ts b/apps/memos-local-plugin/core/llm/types.ts index 4a835caee..a37a2677d 100644 --- a/apps/memos-local-plugin/core/llm/types.ts +++ b/apps/memos-local-plugin/core/llm/types.ts @@ -5,6 +5,8 @@ * Providers stay internal to `core/llm/`. */ +import type { ReasoningConfig as ConfigReasoningConfig } from "../config/schema.js"; + // ─── Providers & config ────────────────────────────────────────────────────── export type LlmProviderName = @@ -15,6 +17,8 @@ export type LlmProviderName = | "bedrock" | "host"; +export type ReasoningConfig = ConfigReasoningConfig; + /** * Resolved LLM config, post-defaults. Subset of `ResolvedConfig.llm` so * the client is unit-testable without the whole config object. @@ -28,6 +32,14 @@ export interface LlmConfig { apiKey?: string; timeoutMs: number; maxRetries: number; + /** OpenRouter provider routing — providers to skip. */ + providerIgnore?: string[]; + /** OpenRouter provider routing — preferred order. */ + providerOrder?: string[]; + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: boolean; + /** OpenRouter-compatible reasoning controls. Omit for model defaults. */ + reasoning?: ReasoningConfig; /** Optional per-call default. Default: 1024. */ maxTokens?: number; /** Extra HTTP headers for outgoing requests. */ diff --git a/apps/memos-local-plugin/core/openrouter.ts b/apps/memos-local-plugin/core/openrouter.ts new file mode 100644 index 000000000..bc8ae2d98 --- /dev/null +++ b/apps/memos-local-plugin/core/openrouter.ts @@ -0,0 +1,34 @@ +/** Shared OpenRouter request routing for OpenAI-compatible providers. */ + +export interface OpenRouterRoutingConfig { + endpoint?: string; + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter?: boolean; + providerIgnore?: string[]; + providerOrder?: string[]; +} + +const OPENROUTER_HOSTS = new Set(["openrouter.ai"]); + +function isOpenRouter(config: OpenRouterRoutingConfig): boolean { + if (config.openRouter) return true; + if (!config.endpoint) return false; + try { + return OPENROUTER_HOSTS.has(new URL(config.endpoint).hostname.toLowerCase()); + } catch { + return false; + } +} + +export function applyOpenRouterProviderRouting( + config: OpenRouterRoutingConfig, + body: Record, +): boolean { + if (!isOpenRouter(config)) return false; + + const provider: Record = {}; + if (config.providerIgnore?.length) provider.ignore = config.providerIgnore; + if (config.providerOrder?.length) provider.order = config.providerOrder; + if (Object.keys(provider).length > 0) body.provider = provider; + return true; +} diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 96721cc0b..934cbf2dc 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -93,6 +93,7 @@ import { registerHostLlmBridge, type HostLlmBridge, } from "../llm/host-bridge.js"; +import type { ReasoningConfig } from "../llm/types.js"; import { createPipeline } from "./orchestrator.js"; import { RECOVERY_REASONS } from "./recovery-constants.js"; @@ -119,6 +120,20 @@ import type { UserFeedback } from "../reward/types.js"; const FINAL_HUB_LLM_FILTER_TIMEOUT_MS = 3_000; const IMPORT_WRITE_BATCH_SIZE = 500; + +type DedicatedLlmConfig = { + provider?: string; + model?: string; + endpoint?: string; + apiKey?: string; + temperature?: number; + timeoutMs?: number; + providerIgnore?: string[]; + providerOrder?: string[]; + openRouter?: boolean; + reasoning?: ReasoningConfig; +}; + export interface BootstrapOptions { agent: AgentKind; namespace?: RuntimeNamespace; @@ -382,7 +397,7 @@ export async function bootstrapMemoryCoreFull( // back to the main `llm` when skillEvolver.model is blank. let reflectLlm: ReturnType | null = null; try { - const evolver = (config as { skillEvolver?: { provider?: string; model?: string; endpoint?: string; apiKey?: string; temperature?: number; timeoutMs?: number } }).skillEvolver; + const evolver = (config as { skillEvolver?: DedicatedLlmConfig }).skillEvolver; const evolverModel = (evolver?.model ?? "").trim(); const evolverProvider = (evolver?.provider ?? "").trim(); if (evolverModel && evolverProvider) { @@ -393,6 +408,10 @@ export async function bootstrapMemoryCoreFull( apiKey: evolver?.apiKey ?? "", temperature: evolver?.temperature ?? 0, timeoutMs: evolver?.timeoutMs ?? 60_000, + providerIgnore: evolver?.providerIgnore, + providerOrder: evolver?.providerOrder, + openRouter: evolver?.openRouter ?? false, + reasoning: evolver?.reasoning, maxRetries: 3, // V7 §0.x — when the user's dedicated skill-evolver model is // down (auth, model name typo, server outage), prefer falling @@ -440,7 +459,7 @@ export async function bootstrapMemoryCoreFull( // impact on companion latency. Blank → falls back to the main `llm`. let l3Llm: ReturnType | null = null; try { - const l3c = (config as { l3Llm?: { provider?: string; model?: string; endpoint?: string; apiKey?: string; temperature?: number; timeoutMs?: number } }).l3Llm; + const l3c = (config as { l3Llm?: DedicatedLlmConfig }).l3Llm; const l3Model = (l3c?.model ?? "").trim(); const l3Provider = (l3c?.provider ?? "").trim(); if (l3Model && l3Provider) { @@ -451,6 +470,10 @@ export async function bootstrapMemoryCoreFull( apiKey: l3c?.apiKey ?? "", temperature: l3c?.temperature ?? 0, timeoutMs: l3c?.timeoutMs ?? 60_000, + providerIgnore: l3c?.providerIgnore, + providerOrder: l3c?.providerOrder, + openRouter: l3c?.openRouter ?? false, + reasoning: l3c?.reasoning, maxRetries: 3, fallbackToHost: true, onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => diff --git a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md index 67915a397..8cfe175e9 100644 --- a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md +++ b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md @@ -57,6 +57,32 @@ storage: words and mixed ASCII+CJK terms searchable in the keyword channel, which helps queries such as `早报`, `配置`, and `API配置`. +For OpenRouter-backed OpenAI-compatible models, you can ask OpenRouter to +skip providers that are unreliable for reflection/scoring prompts: + +```yaml +llm: + provider: openai_compatible + endpoint: https://openrouter.ai/api/v1 + model: google/gemini-2.5-flash-lite + apiKey: sk-or-v1-... + providerIgnore: + - together + - deepinfra + - novita + # providerOrder: + # - google + # - anthropic + # - openai +``` + +The same `providerIgnore` and `providerOrder` fields are accepted on +`skillEvolver`, `l3Llm`, and `embedding` config blocks. +They map to OpenRouter's `provider.ignore` and `provider.order` request +fields and are omitted when empty or when the endpoint is not OpenRouter. +OpenRouter is detected from the normalized `openrouter.ai` hostname. For a +reverse proxy or CNAME, set `openRouter: true` on that config block to opt in. + ### `algorithm` Direct mapping to the V7 spec (γ, support, gain, top-K, etc.). Change only if you know what you're doing — defaults are calibrated for the paper. diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index aae88f8cd..8befbcf7c 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -21,6 +21,22 @@ describe("config/loadConfig", () => { expect(result.config.embedding.provider).toBe(DEFAULT_CONFIG.embedding.provider); }); + it("defaults OpenRouter provider routing lists to empty arrays", () => { + const cfg = resolveConfig({}); + expect(cfg.llm.providerIgnore).toEqual([]); + expect(cfg.llm.providerOrder).toEqual([]); + expect(cfg.skillEvolver.providerIgnore).toEqual([]); + expect(cfg.skillEvolver.providerOrder).toEqual([]); + expect(cfg.l3Llm.providerIgnore).toEqual([]); + expect(cfg.l3Llm.providerOrder).toEqual([]); + expect(cfg.embedding.providerIgnore).toEqual([]); + expect(cfg.embedding.providerOrder).toEqual([]); + expect(cfg.llm.openRouter).toBe(false); + expect(cfg.skillEvolver.openRouter).toBe(false); + expect(cfg.l3Llm.openRouter).toBe(false); + expect(cfg.embedding.openRouter).toBe(false); + }); + it("merges YAML over defaults and preserves unspecified branches", async () => { const yaml = ` viewer: @@ -42,6 +58,63 @@ algorithm: expect(ctx.config.algorithm.skill.minSupport).toBe(DEFAULT_CONFIG.algorithm.skill.minSupport); }); + it("accepts OpenRouter provider routing fields on LLM config branches", () => { + const cfg = resolveConfig({ + llm: { + providerIgnore: ["together", "deepinfra"], + providerOrder: ["google", "anthropic"], + openRouter: true, + }, + skillEvolver: { + providerIgnore: ["novita"], + providerOrder: ["openai"], + openRouter: true, + }, + l3Llm: { + providerIgnore: ["novita"], + providerOrder: ["openai"], + openRouter: true, + }, + embedding: { + providerIgnore: ["deepinfra"], + providerOrder: ["openai"], + openRouter: true, + }, + }); + expect(cfg.llm.providerIgnore).toEqual(["together", "deepinfra"]); + expect(cfg.llm.providerOrder).toEqual(["google", "anthropic"]); + expect(cfg.skillEvolver.providerIgnore).toEqual(["novita"]); + expect(cfg.skillEvolver.providerOrder).toEqual(["openai"]); + expect(cfg.l3Llm.providerIgnore).toEqual(["novita"]); + expect(cfg.l3Llm.providerOrder).toEqual(["openai"]); + expect(cfg.embedding.providerIgnore).toEqual(["deepinfra"]); + expect(cfg.embedding.providerOrder).toEqual(["openai"]); + expect(cfg.llm.openRouter).toBe(true); + expect(cfg.skillEvolver.openRouter).toBe(true); + expect(cfg.l3Llm.openRouter).toBe(true); + expect(cfg.embedding.openRouter).toBe(true); + }); + + it("accepts OpenRouter reasoning effort aliases", () => { + const cfg = resolveConfig({ + llm: { reasoning: { effort: "xhigh" } }, + }); + expect(cfg.llm.reasoning?.effort).toBe("xhigh"); + }); + + it("keeps an empty OpenRouter reasoning block free of overrides", () => { + const cfg = resolveConfig({ + llm: { reasoning: {} }, + }); + expect(cfg.llm.reasoning).toEqual({}); + }); + + it("rejects a non-positive OpenRouter reasoning token budget", () => { + expect(() => resolveConfig({ + llm: { reasoning: { maxTokens: 0 } }, + })).toThrow(/schema validation/); + }); + it("rejects invalid types with a helpful error", async () => { // Don't use makeTmpHome here — it would eagerly loadConfig and throw // before we can capture it. Lay out the dir manually instead. diff --git a/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts b/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts index 5123073cf..cf8657936 100644 --- a/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts +++ b/apps/memos-local-plugin/tests/unit/embedding/providers.test.ts @@ -31,6 +31,7 @@ function cfg(partial: Partial): EmbeddingConfig { model: "m", dimensions: 3, endpoint: "", + openRouter: false, apiKey: "KEY", cache: { enabled: false, maxItems: 0 }, ...partial, @@ -131,6 +132,43 @@ describe("embedding/providers", () => { expect(cap.url).toBe("https://x.example.com/embeddings"); }); + it("adds OpenRouter provider preferences for embedding calls", async () => { + const cap = captureFetchRequest(); + const p = new OpenAiEmbeddingProvider(); + await p.embed( + ["a"], + "document", + ctxFor(cfg({ + provider: "openai_compatible", + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["together", "deepinfra"], + providerOrder: ["openai"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["together", "deepinfra"], + order: ["openai"], + }); + }); + + it("omits provider preferences for non-OpenRouter embedding calls", async () => { + const cap = captureFetchRequest(); + const p = new OpenAiEmbeddingProvider(); + await p.embed( + ["a"], + "document", + ctxFor(cfg({ + provider: "openai_compatible", + endpoint: "https://api.openai.com/v1", + providerIgnore: ["together"], + providerOrder: ["openai"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); + it("rejects malformed response (no data[])", async () => { mockResponses([ new Response(JSON.stringify({ notdata: true }), { status: 200 }), diff --git a/apps/memos-local-plugin/tests/unit/llm/providers.test.ts b/apps/memos-local-plugin/tests/unit/llm/providers.test.ts index 03cb17e8a..49e14658b 100644 --- a/apps/memos-local-plugin/tests/unit/llm/providers.test.ts +++ b/apps/memos-local-plugin/tests/unit/llm/providers.test.ts @@ -37,6 +37,7 @@ function cfg(partial: Partial = {}): LlmConfig { timeoutMs: 5_000, maxRetries: 0, fallbackToHost: false, + openRouter: false, ...partial, }; } @@ -99,6 +100,200 @@ describe("llm/providers", () => { const p = new OpenAiLlmProvider(); await expect(p.complete(msgs, call(), ctxFor(cfg({ apiKey: "" })))).rejects.toBeInstanceOf(MemosError); }); + + it("forwards config.reasoning into an OpenRouter request body", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://openrouter.ai/api/v1", + reasoning: { enabled: false, maxTokens: 8_000 }, + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.reasoning).toEqual({ enabled: false, max_tokens: 8_000 }); + }); + + it("forwards only supported OpenRouter reasoning fields", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://openrouter.ai/api/v1", + reasoning: { + enabled: true, + effort: "high", + maxTokens: 8_000, + misspelledOption: "ignored", + } as unknown as LlmConfig["reasoning"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.reasoning).toEqual({ enabled: true, effort: "high", max_tokens: 8_000 }); + }); + + it("omits reasoning for non-OpenRouter endpoints", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://api.openai.com/v1", + reasoning: { enabled: false }, + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect("reasoning" in body).toBe(false); + }); + + it("omits reasoning from the body when config.reasoning is unset", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete(msgs, call(), ctxFor(cfg())); + const body = JSON.parse(cap.init!.body as string); + expect("reasoning" in body).toBe(false); + }); + + it("omits an empty OpenRouter reasoning block to preserve model defaults", async () => { + const cap = captureFetch({ choices: [{ message: { content: "{}" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://openrouter.ai/api/v1", + reasoning: {}, + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect("reasoning" in body).toBe(false); + }); + + it("adds OpenRouter provider preferences for non-streaming calls", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor( + cfg({ + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["together", "deepinfra"], + providerOrder: ["google", "anthropic"], + }), + ), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["together", "deepinfra"], + order: ["google", "anthropic"], + }); + }); + + it("recognizes OpenRouter hostnames case-insensitively", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://OpenRouter.AI/api/v1", + providerIgnore: ["together"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ ignore: ["together"] }); + }); + + it("does not treat a URL path containing openrouter.ai as OpenRouter", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://proxy.example.com/openrouter.ai/v1", + providerIgnore: ["together"], + })), + ); + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); + + it("allows an explicit OpenRouter opt-in for reverse proxies", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor(cfg({ + endpoint: "https://llm-proxy.example.com/v1", + providerIgnore: ["together"], + openRouter: true, + } as Partial)), + ); + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ ignore: ["together"] }); + }); + + it("omits provider preferences for non-OpenRouter endpoints", async () => { + const cap = captureFetch({ choices: [{ message: { content: "ok" } }] }); + const p = new OpenAiLlmProvider(); + await p.complete( + msgs, + call(), + ctxFor( + cfg({ + endpoint: "https://api.openai.com/v1", + providerIgnore: ["together"], + providerOrder: ["google"], + }), + ), + ); + const body = JSON.parse(cap.init!.body as string); + expect("provider" in body).toBe(false); + }); + + it("adds OpenRouter provider preferences for streaming calls", async () => { + const cap: { url?: string; init?: RequestInit } = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: unknown, init?: unknown) => { + cap.url = String(url); + cap.init = init as RequestInit; + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }), + ); + const p = new OpenAiLlmProvider(); + for await (const _chunk of p.stream( + msgs, + call(), + ctxFor( + cfg({ + endpoint: "https://openrouter.ai/api/v1", + providerIgnore: ["novita"], + providerOrder: ["google"], + reasoning: { enabled: true, maxTokens: 4_000 }, + }), + ), + )) { + // Drain the stream so the fetch request is issued. + } + const body = JSON.parse(cap.init!.body as string); + expect(body.provider).toEqual({ + ignore: ["novita"], + order: ["google"], + }); + expect(body.reasoning).toEqual({ enabled: true, max_tokens: 4_000 }); + }); }); // ─── anthropic ───────────────────────────────────────────────────────────── diff --git a/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts new file mode 100644 index 000000000..512c4cdde --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-llm-config.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { makeTmpHome, type TmpHomeContext } from "../../helpers/tmp-home.js"; +import type { + LlmCallOptions, + LlmClient, + LlmClientStats, + LlmConfig, + LlmMessage, + LlmProviderName, +} from "../../../core/llm/types.js"; +import type { MemoryCore } from "../../../agent-contract/memory-core.js"; + +const capturedLlmConfigs: LlmConfig[] = []; + +function fakeLlmClient(config: LlmConfig): LlmClient { + return { + provider: config.provider as LlmProviderName, + model: config.model, + canStream: false, + async complete(_messages: LlmMessage[] | string, _opts?: LlmCallOptions) { + return { + text: "{}", + provider: config.provider as LlmProviderName, + model: config.model, + servedBy: config.provider as LlmProviderName, + durationMs: 0, + }; + }, + async completeJson() { + return { + value: {} as T, + raw: "{}", + provider: config.provider as LlmProviderName, + model: config.model, + servedBy: config.provider as LlmProviderName, + durationMs: 0, + }; + }, + async *stream() { + yield { delta: "", done: true }; + }, + stats(): LlmClientStats { + return { + requests: 0, + hostFallbacks: 0, + failures: 0, + retries: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + lastOkAt: null, + lastError: null, + lastStatus: null, + }; + }, + resetStats() {}, + async close() {}, + }; +} + +vi.mock("../../../core/llm/client.js", () => ({ + createLlmClient: (config: LlmConfig) => { + capturedLlmConfigs.push(config); + return fakeLlmClient(config); + }, +})); + +describe("bootstrapMemoryCore dedicated LLM config", () => { + let home: TmpHomeContext | null = null; + let core: MemoryCore | null = null; + + afterEach(async () => { + if (core) await core.shutdown(); + if (home) await home.cleanup(); + core = null; + home = null; + capturedLlmConfigs.length = 0; + vi.resetModules(); + }); + + it("forwards OpenRouter provider routing to dedicated LLM clients", async () => { + const { bootstrapMemoryCore } = await import("../../../core/pipeline/memory-core.js"); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: ` +llm: + provider: local_only + model: main +skillEvolver: + provider: openai_compatible + endpoint: https://openrouter.ai/api/v1 + openRouter: true + model: skill-model + apiKey: sk-test + providerIgnore: + - together + providerOrder: + - anthropic +l3Llm: + provider: openai_compatible + endpoint: https://llm-proxy.example.com/v1 + openRouter: true + model: l3-model + apiKey: sk-test + providerIgnore: + - novita + providerOrder: + - openai + reasoning: + enabled: true + maxTokens: 4000 +`, + }); + + core = await bootstrapMemoryCore({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "bootstrap-test", + }); + + expect(capturedLlmConfigs.find((cfg) => cfg.model === "skill-model")).toMatchObject({ + providerIgnore: ["together"], + providerOrder: ["anthropic"], + openRouter: true, + }); + expect(capturedLlmConfigs.find((cfg) => cfg.model === "l3-model")).toMatchObject({ + providerIgnore: ["novita"], + providerOrder: ["openai"], + openRouter: true, + reasoning: { enabled: true, maxTokens: 4_000 }, + }); + }); + + it("normalizes missing dedicated OpenRouter flags to false", async () => { + const { bootstrapMemoryCore } = await import("../../../core/pipeline/memory-core.js"); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: ` +llm: + provider: local_only + model: main +skillEvolver: + provider: openai_compatible + model: skill-model +l3Llm: + provider: openai_compatible + model: l3-model +`, + }); + const config = { + ...home.config, + skillEvolver: { ...home.config.skillEvolver, openRouter: undefined }, + l3Llm: { ...home.config.l3Llm, openRouter: undefined }, + } as unknown as typeof home.config; + + core = await bootstrapMemoryCore({ + agent: "openclaw", + home: home.home, + config, + pkgVersion: "bootstrap-test", + }); + + expect(capturedLlmConfigs.find((cfg) => cfg.model === "skill-model")?.openRouter).toBe(false); + expect(capturedLlmConfigs.find((cfg) => cfg.model === "l3-model")?.openRouter).toBe(false); + }); +}); From 0a0ad89b221fe850765304f03923f1a4fd20c144 Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:29:31 +0800 Subject: [PATCH 12/33] refactor: summarize search and embedding logs --- src/memos/api/handlers/search_handler.py | 10 +- src/memos/embedders/ark.py | 3 +- src/memos/embedders/base.py | 55 +++++++++++ src/memos/embedders/ollama.py | 3 +- src/memos/embedders/sentence_transformer.py | 3 +- src/memos/embedders/universal_api.py | 33 +------ src/memos/log.py | 72 ++++++++++++++ .../tree_text_memory/retrieve/searcher.py | 10 +- src/memos/multi_mem_cube/single_cube.py | 7 +- tests/embedders/test_base.py | 78 ++++++++++++++- tests/test_log.py | 95 +++++++++++++++++++ 11 files changed, 321 insertions(+), 48 deletions(-) diff --git a/src/memos/api/handlers/search_handler.py b/src/memos/api/handlers/search_handler.py index 03e6977ad..2f170a8a0 100644 --- a/src/memos/api/handlers/search_handler.py +++ b/src/memos/api/handlers/search_handler.py @@ -16,7 +16,7 @@ from memos.api.handlers.formatters_handler import rerank_knowledge_mem from memos.api.product_models import APISearchRequest, SearchResponse from memos.dream.contextualization import CONTEXT_MEMORY_TYPE -from memos.log import get_logger +from memos.log import get_logger, summarize_search_request, summarize_search_results from memos.memories.textual.tree_text_memory.retrieve.retrieve_utils import ( cosine_similarity_matrix, ) @@ -77,7 +77,10 @@ def handle_search_memories(self, search_req: APISearchRequest) -> SearchResponse Returns: SearchResponse with formatted results """ - self.logger.info(f"[SearchHandler] Search Req is: {search_req}") + self.logger.info( + "[SearchHandler] Search request summary: %s", + summarize_search_request(search_req), + ) # Use deepcopy to avoid modifying the original request object search_req_local = copy.deepcopy(search_req) @@ -137,7 +140,8 @@ def handle_search_memories(self, search_req: APISearchRequest) -> SearchResponse results = hooked_results self.logger.info( - f"[SearchHandler] Final search results: count={len(results)} results={results}" + "[SearchHandler] Final search result summary: %s", + summarize_search_results(results), ) return SearchResponse( diff --git a/src/memos/embedders/ark.py b/src/memos/embedders/ark.py index a8b47e200..aa183fcdc 100644 --- a/src/memos/embedders/ark.py +++ b/src/memos/embedders/ark.py @@ -1,6 +1,6 @@ from memos.configs.embedder import ArkEmbedderConfig from memos.dependency import require_python_package -from memos.embedders.base import BaseEmbedder +from memos.embedders.base import BaseEmbedder, log_embedding_call from memos.log import get_logger @@ -35,6 +35,7 @@ def __init__(self, config: ArkEmbedderConfig): # Initialize ark client self.client = Ark(api_key=self.config.api_key, base_url=self.config.api_base) + @log_embedding_call def embed(self, texts: list[str]) -> list[list[float]]: """ Generate embeddings for the given texts. diff --git a/src/memos/embedders/base.py b/src/memos/embedders/base.py index e46611d1a..4cc8e6c85 100644 --- a/src/memos/embedders/base.py +++ b/src/memos/embedders/base.py @@ -1,8 +1,63 @@ +import functools import re +import time from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Any, TypeVar, cast from memos.configs.embedder import BaseEmbedderConfig +from memos.log import get_logger, text_hash + + +logger = get_logger(__name__) +EmbeddingCallable = TypeVar("EmbeddingCallable", bound=Callable[..., Any]) + + +def log_embedding_call(func: EmbeddingCallable) -> EmbeddingCallable: + """Log embedding request dimensions and timing without text or vectors.""" + + @functools.wraps(func) + def wrapper(self, texts, *args, **kwargs): + normalized_texts = [texts] if isinstance(texts, str) else list(texts or []) + text_lengths = [len(str(text or "")) for text in normalized_texts] + config = getattr(self, "config", None) + model = getattr(config, "model_name_or_path", None) or "unknown" + backup_model = getattr(config, "backup_model_name_or_path", None) or "none" + backup_enabled = bool(getattr(self, "use_backup_client", False)) + started_at = time.perf_counter() + status = "success" + error_type = None + try: + return func(self, texts, *args, **kwargs) + except Exception as exc: + status = "failed" + error_type = type(exc).__name__ + raise + finally: + elapsed_ms = (time.perf_counter() - started_at) * 1000 + log_message = ( + "Embedding request model=%s backup_model=%s backup_enabled=%s " + "batch_size=%d total_chars=%d max_chars=%d text_hash=%s " + "elapsed_ms=%.2f status=%s" + ) + log_values = ( + model, + backup_model, + backup_enabled, + len(normalized_texts), + sum(text_lengths), + max(text_lengths, default=0), + text_hash(normalized_texts), + elapsed_ms, + status, + ) + if error_type is None: + logger.info(log_message, *log_values) + else: + logger.info(log_message + " error_type=%s", *log_values, error_type) + + return cast("EmbeddingCallable", wrapper) def _count_tokens_for_embedding(text: str) -> int: diff --git a/src/memos/embedders/ollama.py b/src/memos/embedders/ollama.py index dfd8e230d..789dfb9c0 100644 --- a/src/memos/embedders/ollama.py +++ b/src/memos/embedders/ollama.py @@ -1,7 +1,7 @@ from ollama import Client from memos.configs.embedder import OllamaEmbedderConfig -from memos.embedders.base import BaseEmbedder +from memos.embedders.base import BaseEmbedder, log_embedding_call from memos.log import get_logger @@ -57,6 +57,7 @@ def _ensure_model_exists(self): except Exception as e: logger.warning(f"Could not verify model existence: {e}") + @log_embedding_call def embed(self, texts: list[str]) -> list[list[float]]: """ Generate embeddings for the given texts. diff --git a/src/memos/embedders/sentence_transformer.py b/src/memos/embedders/sentence_transformer.py index de086cb49..7b1cad146 100644 --- a/src/memos/embedders/sentence_transformer.py +++ b/src/memos/embedders/sentence_transformer.py @@ -1,6 +1,6 @@ from memos.configs.embedder import SenTranEmbedderConfig from memos.dependency import require_python_package -from memos.embedders.base import BaseEmbedder +from memos.embedders.base import BaseEmbedder, log_embedding_call from memos.log import get_logger @@ -32,6 +32,7 @@ def __init__(self, config: SenTranEmbedderConfig): # Get embedding dimensions from the model self.config.embedding_dims = self.model.get_sentence_embedding_dimension() + @log_embedding_call def embed(self, texts: list[str]) -> list[list[float]]: """ Generate embeddings for the given texts. diff --git a/src/memos/embedders/universal_api.py b/src/memos/embedders/universal_api.py index 66ed4e62c..24a022eae 100644 --- a/src/memos/embedders/universal_api.py +++ b/src/memos/embedders/universal_api.py @@ -1,32 +1,17 @@ import asyncio import os -import time from openai import AzureOpenAI as AzureClient from openai import OpenAI as OpenAIClient from memos.configs.embedder import UniversalAPIEmbedderConfig -from memos.embedders.base import BaseEmbedder +from memos.embedders.base import BaseEmbedder, log_embedding_call from memos.log import get_logger -from memos.utils import timed_with_status logger = get_logger(__name__) -def _embedding_log_extra_args(embedder: "UniversalAPIEmbedder", texts: list[str] | str) -> dict: - text_items = [texts] if isinstance(texts, str) else texts - return { - "model_name_or_path": getattr( - embedder.config, "model_name_or_path", "text-embedding-3-large" - ), - "backup_model_name_or_path": getattr(embedder.config, "backup_model_name_or_path", None), - "use_backup_client": getattr(embedder, "use_backup_client", False), - "text_len": len(text_items), - "text_content": text_items, - } - - def _sanitize_unicode(text: str) -> str: """ Remove Unicode surrogates and other problematic characters. @@ -71,10 +56,7 @@ def __init__(self, config: UniversalAPIEmbedderConfig): else None, ) - @timed_with_status( - log_prefix="model_timed_embedding", - log_extra_args=_embedding_log_extra_args, - ) + @log_embedding_call def embed(self, texts: list[str]) -> list[list[float]]: if isinstance(texts, str): texts = [texts] @@ -82,7 +64,6 @@ def embed(self, texts: list[str]) -> list[list[float]]: texts = [_sanitize_unicode(t) for t in texts] # Truncate texts if max_tokens is configured texts = self._truncate_texts(texts) - logger.info(f"Embeddings request with input: {texts}") if self.provider == "openai" or self.provider == "azure": try: @@ -92,18 +73,17 @@ async def _create_embeddings(): input=texts, ) - init_time = time.time() response = asyncio.run( asyncio.wait_for( _create_embeddings(), timeout=int(os.getenv("MOS_EMBEDDER_TIMEOUT", 5)) ) ) - logger.info(f"Embeddings request succeeded with {time.time() - init_time} seconds") return [r.embedding for r in response.data] except Exception as e: if self.use_backup_client: logger.warning( - f"Embeddings request ended with {type(e).__name__} error: {e}, try backup client" + "Embedding request failed error_type=%s; trying backup client", + type(e).__name__, ) try: @@ -117,17 +97,12 @@ async def _create_embeddings_backup(): input=texts, ) - init_time = time.time() response = asyncio.run( asyncio.wait_for( _create_embeddings_backup(), timeout=int(os.getenv("MOS_EMBEDDER_TIMEOUT", 5)), ) ) - logger.info( - f"Backup embeddings request succeeded with {time.time() - init_time} seconds" - ) - logger.info(f"Backup embeddings request response: {response}") return [r.embedding for r in response.data] except Exception as e: raise ValueError(f"Backup embeddings request ended with error: {e}") from e diff --git a/src/memos/log.py b/src/memos/log.py index c18bd2118..0e521df76 100644 --- a/src/memos/log.py +++ b/src/memos/log.py @@ -1,13 +1,17 @@ import atexit +import hashlib import logging import os import threading import time +from collections import Counter +from collections.abc import Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from logging.config import dictConfig from pathlib import Path from sys import stdout +from typing import Any import requests @@ -31,6 +35,74 @@ _LOGGING_CONFIGURED_PID: int | None = None +def text_hash(texts: str | Sequence[str]) -> str: + """Return a stable short hash without retaining the source text.""" + normalized_texts = [texts] if isinstance(texts, str) else texts + digest = hashlib.sha256() + for text in normalized_texts: + encoded = str(text or "").encode("utf-8", errors="replace") + digest.update(len(encoded).to_bytes(8, byteorder="big")) + digest.update(encoded) + return digest.hexdigest()[:16] + + +def summarize_search_request(request: Any) -> dict[str, Any]: + """Summarize search controls without logging query or user content.""" + query = str(getattr(request, "query", "") or "") + mode = getattr(request, "mode", None) + readable_cube_ids = getattr(request, "readable_cube_ids", None) or [] + views = getattr(request, "include_memory_view", None) or [] + return { + "query_chars": len(query), + "query_hash": text_hash(query), + "mode": getattr(mode, "value", mode), + "readable_cube_count": len(readable_cube_ids), + "views": list(views), + "top_k": getattr(request, "memory_limit_number", None), + "dedup": getattr(request, "dedup", None), + "rerank": getattr(request, "rerank", None), + } + + +def summarize_search_results(results: Mapping[str, Any]) -> dict[str, Any]: + """Count result buckets and items without serializing result payloads.""" + bucket_counts: dict[str, int] = {} + item_counts: dict[str, int] = {} + for key, value in results.items(): + if not isinstance(value, list): + continue + bucket_counts[key] = len(value) + item_counts[key] = sum( + len(bucket.get("memories") or []) + if isinstance(bucket, Mapping) and isinstance(bucket.get("memories"), list) + else 1 + for bucket in value + ) + return { + "bucket_counts": bucket_counts, + "item_counts": item_counts, + "total_items": sum(item_counts.values()), + } + + +def summarize_textual_memories(memories: Sequence[Any]) -> dict[str, Any]: + """Count textual memories by type without reading their content or metadata values.""" + type_counts: Counter[str] = Counter() + for item in memories: + metadata = ( + item.get("metadata") if isinstance(item, Mapping) else getattr(item, "metadata", None) + ) + if isinstance(metadata, Mapping): + memory_type = metadata.get("memory_type") + else: + memory_type = getattr(metadata, "memory_type", None) + type_counts[str(memory_type or "unknown")] += 1 + return { + "total_items": len(memories), + "type_counts": dict(type_counts), + } + + def _setup_logfile() -> Path: """ensure the logger filepath is in place diff --git a/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py b/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py index cd27d92a1..5c9cce78e 100644 --- a/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py +++ b/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py @@ -9,7 +9,7 @@ from memos.embedders.factory import OllamaEmbedder from memos.graph_dbs.factory import Neo4jGraphDB from memos.llms.factory import AzureLLM, OllamaLLM, OpenAILLM -from memos.log import get_logger +from memos.log import get_logger, summarize_textual_memories from memos.memories.textual.item import SearchedTreeNodeTextualMemoryMetadata, TextualMemoryItem from memos.memories.textual.tree_text_memory.retrieve.bm25_util import EnhancedBM25 from memos.memories.textual.tree_text_memory.retrieve.retrieve_utils import ( @@ -277,13 +277,7 @@ def search( dedup=dedup, ) - logger.info(f"[SEARCH] Done. Total {len(final_results)} results.") - res_results = "" - for _num_i, result in enumerate(final_results): - res_results += "\n" + ( - result.id + "|" + result.metadata.memory_type + "|" + result.memory - ) - logger.info(f"[SEARCH] Results. {res_results}") + logger.info("[SEARCH] Result summary: %s", summarize_textual_memories(final_results)) return final_results @timed diff --git a/src/memos/multi_mem_cube/single_cube.py b/src/memos/multi_mem_cube/single_cube.py index f84fc60e1..27399259a 100644 --- a/src/memos/multi_mem_cube/single_cube.py +++ b/src/memos/multi_mem_cube/single_cube.py @@ -12,7 +12,7 @@ format_memory_item, post_process_textual_mem, ) -from memos.log import get_logger +from memos.log import get_logger, summarize_search_request, summarize_search_results from memos.mem_reader.utils import parse_keep_filter_response from memos.mem_scheduler.schemas.message_schemas import ScheduleMessageItem from memos.mem_scheduler.schemas.task_schemas import ( @@ -104,7 +104,7 @@ def search_memories(self, search_req: APISearchRequest) -> dict[str, Any]: mem_cube_id=self.cube_id, session_id=search_req.session_id or "default_session", ) - self.logger.info(f"Search Req is: {search_req}") + self.logger.info("Search request summary: %s", summarize_search_request(search_req)) memories_result: MOSSearchResult = { "text_mem": [], @@ -129,8 +129,7 @@ def search_memories(self, search_req: APISearchRequest) -> dict[str, Any]: self.cube_id, ) - self.logger.info(f"Search memories result: {memories_result}") - self.logger.info(f"Search {len(memories_result)} memories.") + self.logger.info("Search result summary: %s", summarize_search_results(memories_result)) return memories_result @timed diff --git a/tests/embedders/test_base.py b/tests/embedders/test_base.py index 029d3a4c2..47a05864d 100644 --- a/tests/embedders/test_base.py +++ b/tests/embedders/test_base.py @@ -1,6 +1,82 @@ -from memos.embedders.base import BaseEmbedder +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from memos.embedders.base import BaseEmbedder, log_embedding_call from tests.utils import check_module_base_class def test_base_embedder_class(): check_module_base_class(BaseEmbedder) + + +def test_log_embedding_call_records_safe_structured_summary(): + class StubEmbedder: + config = SimpleNamespace(model_name_or_path="embedding-model") + + @log_embedding_call + def embed(self, texts: list[str]) -> list[list[float]]: + return [[0.1, 0.2] for _ in texts] + + private_texts = ["private first text", "private second text"] + with patch("memos.embedders.base.logger") as mock_logger: + result = StubEmbedder().embed(private_texts) + + assert result == [[0.1, 0.2], [0.1, 0.2]] + log_args = mock_logger.info.call_args.args + rendered = log_args[0] % log_args[1:] + assert "model=embedding-model" in rendered + assert "batch_size=2" in rendered + assert "total_chars=37" in rendered + assert "max_chars=19" in rendered + assert "text_hash=" in rendered + assert "elapsed_ms=" in rendered + assert "status=success" in rendered + assert "private first text" not in rendered + assert "private second text" not in rendered + assert "0.1" not in rendered + + +def test_log_embedding_call_records_error_type_without_exception_content(): + class FailingEmbedder: + config = SimpleNamespace(model_name_or_path="embedding-model") + + @log_embedding_call + def embed(self, texts: list[str]) -> list[list[float]]: + raise ValueError(f"failed to embed {texts}") + + with ( + patch("memos.embedders.base.logger") as mock_logger, + pytest.raises(ValueError, match="private failing text"), + ): + FailingEmbedder().embed(["private failing text"]) + + log_args = mock_logger.info.call_args.args + rendered = log_args[0] % log_args[1:] + assert "status=failed" in rendered + assert "error_type=ValueError" in rendered + assert "private failing text" not in rendered + + +def test_log_embedding_call_records_backup_model_without_text_content(): + class StubEmbedder: + config = SimpleNamespace( + model_name_or_path="primary-model", + backup_model_name_or_path="backup-model", + ) + use_backup_client = True + + @log_embedding_call + def embed(self, texts: list[str]) -> list[list[float]]: + return [[0.1] for _ in texts] + + with patch("memos.embedders.base.logger") as mock_logger: + StubEmbedder().embed(["private input"]) + + log_args = mock_logger.info.call_args.args + rendered = log_args[0] % log_args[1:] + assert "model=primary-model" in rendered + assert "backup_model=backup-model" in rendered + assert "backup_enabled=True" in rendered + assert "private input" not in rendered diff --git a/tests/test_log.py b/tests/test_log.py index 5387adc34..5b72fb9c6 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,6 +1,8 @@ import logging import os +from types import SimpleNamespace + from dotenv import load_dotenv from memos import log @@ -59,3 +61,96 @@ def getpid(): log.get_logger("child") assert len(calls) == 2 + + +def test_summarize_search_request_uses_metadata_and_query_hash_only(): + request = SimpleNamespace( + query="private query content", + user_id="private-user", + mode="fast", + readable_cube_ids=["cube-a", "cube-b"], + include_memory_view=["detail_factual", "preference"], + memory_limit_number=6, + dedup="mmr", + rerank=True, + ) + + summary = log.summarize_search_request(request) + + assert summary["query_chars"] == len(request.query) + assert len(summary["query_hash"]) == 16 + assert summary["mode"] == "fast" + assert summary["readable_cube_count"] == 2 + assert summary["views"] == ["detail_factual", "preference"] + assert summary["top_k"] == 6 + assert summary["dedup"] == "mmr" + assert summary["rerank"] is True + assert request.query not in str(summary) + assert request.user_id not in str(summary) + + +def test_summarize_search_results_only_counts_buckets_and_items(): + results = { + "text_mem": [ + { + "cube_id": "cube-a", + "memories": [ + { + "memory": "private memory value", + "metadata": { + "embedding": [0.1] * 256, + "properties": {"secret": 1}, + }, + }, + {"memory": "another private value"}, + ], + } + ], + "pref_mem": [{"cube_id": "cube-a", "memories": [{"memory": "private preference"}]}], + "skill_mem": [], + "pref_note": "private preference note", + } + + summary = log.summarize_search_results(results) + + assert summary == { + "bucket_counts": {"text_mem": 1, "pref_mem": 1, "skill_mem": 0}, + "item_counts": {"text_mem": 2, "pref_mem": 1, "skill_mem": 0}, + "total_items": 3, + } + rendered = str(summary) + assert "private memory value" not in rendered + assert "0.1" not in rendered + assert "secret" not in rendered + assert "private preference note" not in rendered + assert len(rendered) <= len(str(results)) * 0.2 + + +def test_summarize_textual_memories_only_counts_types(): + memories = [ + SimpleNamespace( + memory="private long-term memory", + metadata=SimpleNamespace( + memory_type="LongTermMemory", + embedding=[0.1, 0.2], + ), + ), + SimpleNamespace( + memory="private user memory", + metadata=SimpleNamespace( + memory_type="UserMemory", + properties={"secret": True}, + ), + ), + ] + + summary = log.summarize_textual_memories(memories) + + assert summary == { + "total_items": 2, + "type_counts": {"LongTermMemory": 1, "UserMemory": 1}, + } + rendered = str(summary) + assert "private long-term memory" not in rendered + assert "0.1" not in rendered + assert "secret" not in rendered From 54ad5e4dbadbf356176a0194cfb69366bf3fd422 Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:22:03 +0800 Subject: [PATCH 13/33] perf: cache and coalesce embedding requests --- src/memos/embedders/cache.py | 229 +++++++++++++++++++++++++++++++++ src/memos/embedders/factory.py | 7 +- tests/embedders/test_cache.py | 185 ++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/memos/embedders/cache.py create mode 100644 tests/embedders/test_cache.py diff --git a/src/memos/embedders/cache.py b/src/memos/embedders/cache.py new file mode 100644 index 000000000..963d2d725 --- /dev/null +++ b/src/memos/embedders/cache.py @@ -0,0 +1,229 @@ +import os +import threading + +from collections import Counter +from concurrent.futures import Future +from typing import Any + +from cachetools import LRUCache, TTLCache + +from memos.context.context import get_current_trace_id +from memos.embedders.base import BaseEmbedder +from memos.exceptions import EmbedderError +from memos.log import get_logger + + +logger = get_logger(__name__) + + +_OPTIMIZATION_ENABLED_ENV = "MEMOS_EMBEDDING_OPTIMIZATION_ENABLED" +_CACHE_TTL_ENV = "MEMOS_EMBEDDING_CACHE_TTL_SECONDS" +_CACHE_MAX_SIZE_ENV = "MEMOS_EMBEDDING_CACHE_MAX_SIZE" +_REQUEST_CACHE_TTL_ENV = "MEMOS_EMBEDDING_REQUEST_CACHE_TTL_SECONDS" +_REQUEST_CACHE_MAX_REQUESTS_ENV = "MEMOS_EMBEDDING_REQUEST_CACHE_MAX_REQUESTS" + +_DEFAULT_CACHE_TTL_SECONDS = 30.0 +_DEFAULT_CACHE_MAX_SIZE = 4096 +_DEFAULT_REQUEST_CACHE_TTL_SECONDS = 60.0 +_DEFAULT_REQUEST_CACHE_MAX_REQUESTS = 1024 + +_INVALID_REQUEST_IDS = {None, "", "trace-id"} + +CachedVector = tuple[float, ...] + + +def _env_enabled(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def embedding_optimization_enabled() -> bool: + return _env_enabled(_OPTIMIZATION_ENABLED_ENV) + + +def _env_float(name: str, default: float, minimum: float = 0.0) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return max(minimum, float(raw)) + except ValueError: + logger.warning("Invalid %s=%r; using default %s", name, raw, default) + return default + + +def _env_int(name: str, default: int, minimum: int = 1) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return max(minimum, int(raw)) + except ValueError: + logger.warning("Invalid %s=%r; using default %s", name, raw, default) + return default + + +class CachingEmbedder(BaseEmbedder): + """Add exact caches and singleflight coordination to an embedder.""" + + def __init__(self, backend: BaseEmbedder): + self._backend = backend + self.config = backend.config + self._lock = threading.RLock() + self._inflight: dict[str, Future[CachedVector]] = {} + self._stats: Counter[str] = Counter() + + cache_ttl = _env_float(_CACHE_TTL_ENV, _DEFAULT_CACHE_TTL_SECONDS) + cache_max_size = _env_int(_CACHE_MAX_SIZE_ENV, _DEFAULT_CACHE_MAX_SIZE) + self._cache: TTLCache[str, CachedVector] | None = ( + TTLCache(maxsize=cache_max_size, ttl=cache_ttl) if cache_ttl > 0 else None + ) + + request_cache_ttl = _env_float(_REQUEST_CACHE_TTL_ENV, _DEFAULT_REQUEST_CACHE_TTL_SECONDS) + request_cache_max_requests = _env_int( + _REQUEST_CACHE_MAX_REQUESTS_ENV, _DEFAULT_REQUEST_CACHE_MAX_REQUESTS + ) + self._request_cache_text_limit = cache_max_size + self._request_caches: TTLCache[str, LRUCache[str, CachedVector]] | None = ( + TTLCache(maxsize=request_cache_max_requests, ttl=request_cache_ttl) + if request_cache_ttl > 0 + else None + ) + + def __getattr__(self, name: str) -> Any: + return getattr(self._backend, name) + + def embed(self, texts: list[str]) -> list[list[float]]: + if not embedding_optimization_enabled(): + return self._backend.embed(texts) + if isinstance(texts, str): + texts = [texts] + if not texts: + return [] + + unique_texts = list(dict.fromkeys(texts)) + request_id = get_current_trace_id() + resolved: dict[str, CachedVector] = {} + owned: dict[str, Future[CachedVector]] = {} + waiting: dict[str, Future[CachedVector]] = {} + local_stats = Counter( + { + "batch_dedup_hits": len(texts) - len(unique_texts), + "request_hits": 0, + "ttl_hits": 0, + "singleflight_joins": 0, + "misses": 0, + } + ) + + with self._lock: + request_cache = self._get_request_cache(request_id) + for text in unique_texts: + if request_cache is not None and text in request_cache: + resolved[text] = request_cache[text] + local_stats["request_hits"] += 1 + elif self._cache is not None and text in self._cache: + resolved[text] = self._cache[text] + if request_cache is not None: + request_cache[text] = resolved[text] + local_stats["ttl_hits"] += 1 + elif text in self._inflight: + waiting[text] = self._inflight[text] + local_stats["singleflight_joins"] += 1 + else: + future: Future[CachedVector] = Future() + self._inflight[text] = future + owned[text] = future + local_stats["misses"] += 1 + + self._stats.update(local_stats) + + if owned: + owned_texts = list(owned) + with self._lock: + self._stats["backend_calls"] += 1 + self._stats["backend_texts"] += len(owned_texts) + try: + computed = self._backend.embed(owned_texts) + if len(computed) != len(owned_texts): + raise EmbedderError( + "Embedding backend returned a different number of vectors than texts" + ) + computed_vectors = [tuple(vector) for vector in computed] + except Exception as exc: + with self._lock: + self._stats["backend_errors"] += 1 + for text, future in owned.items(): + self._inflight.pop(text, None) + future.set_exception(exc) + raise + + with self._lock: + for text, vector in zip(owned_texts, computed_vectors, strict=True): + resolved[text] = vector + if self._cache is not None: + self._cache[text] = vector + if request_cache is not None: + request_cache[text] = vector + self._inflight.pop(text, None) + owned[text].set_result(vector) + + for text, future in waiting.items(): + vector = future.result() + resolved[text] = vector + if request_cache is not None: + with self._lock: + request_cache[text] = vector + + logger.info( + "embedding cache summary batch_size=%d unique_texts=%d " + "request_hits=%d ttl_hits=%d batch_dedup_hits=%d " + "singleflight_joins=%d misses=%d", + len(texts), + len(unique_texts), + local_stats["request_hits"], + local_stats["ttl_hits"], + local_stats["batch_dedup_hits"], + local_stats["singleflight_joins"], + local_stats["misses"], + ) + return [list(resolved[text]) for text in texts] + + def _get_request_cache(self, request_id: str | None) -> LRUCache[str, CachedVector] | None: + if request_id in _INVALID_REQUEST_IDS or self._request_caches is None: + return None + request_cache = self._request_caches.get(request_id) + if request_cache is None: + request_cache = LRUCache(maxsize=self._request_cache_text_limit) + self._request_caches[request_id] = request_cache + return request_cache + + def cache_info(self) -> dict[str, int]: + with self._lock: + info = dict(self._stats) + for key in ( + "batch_dedup_hits", + "request_hits", + "ttl_hits", + "singleflight_joins", + "misses", + "backend_calls", + "backend_texts", + "backend_errors", + ): + info.setdefault(key, 0) + info["ttl_cache_size"] = len(self._cache) if self._cache is not None else 0 + info["request_cache_count"] = ( + len(self._request_caches) if self._request_caches is not None else 0 + ) + info["inflight"] = len(self._inflight) + return info + + def clear_cache(self) -> None: + with self._lock: + if self._cache is not None: + self._cache.clear() + if self._request_caches is not None: + self._request_caches.clear() diff --git a/src/memos/embedders/factory.py b/src/memos/embedders/factory.py index be14db9e2..4f9d1bda8 100644 --- a/src/memos/embedders/factory.py +++ b/src/memos/embedders/factory.py @@ -3,6 +3,7 @@ from memos.configs.embedder import EmbedderConfigFactory from memos.embedders.ark import ArkEmbedder from memos.embedders.base import BaseEmbedder +from memos.embedders.cache import CachingEmbedder, embedding_optimization_enabled from memos.embedders.ollama import OllamaEmbedder from memos.embedders.sentence_transformer import SenTranEmbedder from memos.embedders.universal_api import UniversalAPIEmbedder @@ -18,6 +19,7 @@ class EmbedderFactory(BaseEmbedder): "ark": ArkEmbedder, "universal_api": UniversalAPIEmbedder, } + cacheable_backends: ClassVar[set[str]] = {"ollama", "ark", "universal_api"} @classmethod @singleton_factory() @@ -26,4 +28,7 @@ def from_config(cls, config_factory: EmbedderConfigFactory) -> BaseEmbedder: if backend not in cls.backend_to_class: raise ValueError(f"Invalid backend: {backend}") embedder_class = cls.backend_to_class[backend] - return embedder_class(config_factory.config) + embedder = embedder_class(config_factory.config) + if backend in cls.cacheable_backends and embedding_optimization_enabled(): + return CachingEmbedder(embedder) + return embedder diff --git a/tests/embedders/test_cache.py b/tests/embedders/test_cache.py new file mode 100644 index 000000000..2baff6780 --- /dev/null +++ b/tests/embedders/test_cache.py @@ -0,0 +1,185 @@ +import threading +import time + +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from memos.configs.embedder import EmbedderConfigFactory +from memos.context.context import RequestContext, set_request_context +from memos.embedders.cache import CachingEmbedder +from memos.embedders.factory import EmbedderFactory + + +@pytest.fixture(autouse=True) +def clear_request_context(): + set_request_context(None) + yield + set_request_context(None) + + +def _backend(side_effect=None): + backend = MagicMock() + backend.config = SimpleNamespace(model_name_or_path="embedding-model") + backend.embed.side_effect = side_effect or ( + lambda texts: [[float(len(text)), float(index)] for index, text in enumerate(texts)] + ) + return backend + + +def _enable_optimization(monkeypatch, ttl_seconds="0"): + monkeypatch.setenv("MEMOS_EMBEDDING_OPTIMIZATION_ENABLED", "true") + monkeypatch.setenv("MEMOS_EMBEDDING_CACHE_TTL_SECONDS", ttl_seconds) + monkeypatch.setenv("MEMOS_EMBEDDING_CACHE_MAX_SIZE", "32") + monkeypatch.setenv("MEMOS_EMBEDDING_REQUEST_CACHE_TTL_SECONDS", "60") + monkeypatch.setenv("MEMOS_EMBEDDING_REQUEST_CACHE_MAX_REQUESTS", "8") + + +def test_disabled_cache_preserves_backend_batch(monkeypatch): + monkeypatch.setenv("MEMOS_EMBEDDING_OPTIMIZATION_ENABLED", "false") + backend = _backend() + embedder = CachingEmbedder(backend) + + first = embedder.embed(["same", "same"]) + second = embedder.embed(["same", "same"]) + + assert first == second + assert backend.embed.call_count == 2 + backend.embed.assert_called_with(["same", "same"]) + + +def test_enabled_cache_treats_string_as_one_text(monkeypatch): + _enable_optimization(monkeypatch) + backend = _backend() + embedder = CachingEmbedder(backend) + + result = embedder.embed("whole query") + + assert result == [[11.0, 0.0]] + backend.embed.assert_called_once_with(["whole query"]) + + +def test_reuses_duplicate_texts_within_request(monkeypatch): + _enable_optimization(monkeypatch) + backend = _backend() + embedder = CachingEmbedder(backend) + set_request_context(RequestContext(trace_id="request-a")) + + first = embedder.embed(["same", "same", "other"]) + first[0][0] = 999.0 + second = embedder.embed(["same"]) + + backend.embed.assert_called_once_with(["same", "other"]) + assert first[1] == [4.0, 0.0] + assert second == [[4.0, 0.0]] + assert embedder.cache_info()["request_hits"] == 1 + + +def test_short_ttl_cache_reuses_text_across_requests(monkeypatch): + _enable_optimization(monkeypatch, ttl_seconds="60") + backend = _backend() + embedder = CachingEmbedder(backend) + + set_request_context(RequestContext(trace_id="request-a")) + first = embedder.embed(["same"]) + set_request_context(RequestContext(trace_id="request-b")) + second = embedder.embed(["same"]) + + assert first == second + backend.embed.assert_called_once_with(["same"]) + assert embedder.cache_info()["ttl_hits"] == 1 + + +def test_partial_cache_hit_preserves_input_order(monkeypatch): + _enable_optimization(monkeypatch, ttl_seconds="60") + backend = _backend() + embedder = CachingEmbedder(backend) + + assert embedder.embed(["cached"]) == [[6.0, 0.0]] + result = embedder.embed(["new-a", "cached", "new-b", "new-a"]) + + assert result == [ + [5.0, 0.0], + [6.0, 0.0], + [5.0, 1.0], + [5.0, 0.0], + ] + assert backend.embed.call_args_list[-1].args[0] == ["new-a", "new-b"] + + +def test_request_cache_does_not_leak_when_ttl_disabled(monkeypatch): + _enable_optimization(monkeypatch) + backend = _backend() + embedder = CachingEmbedder(backend) + + set_request_context(RequestContext(trace_id="request-a")) + embedder.embed(["same"]) + set_request_context(RequestContext(trace_id="request-b")) + embedder.embed(["same"]) + + assert backend.embed.call_count == 2 + + +def test_concurrent_same_text_uses_single_backend_call(monkeypatch): + _enable_optimization(monkeypatch, ttl_seconds="60") + backend_started = threading.Event() + release_backend = threading.Event() + + def blocking_embed(texts): + backend_started.set() + assert release_backend.wait(timeout=2) + return [[1.0, 2.0] for _ in texts] + + backend = _backend(blocking_embed) + embedder = CachingEmbedder(backend) + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(embedder.embed, ["same"]) + assert backend_started.wait(timeout=2) + second = executor.submit(embedder.embed, ["same"]) + + deadline = time.monotonic() + 2 + while embedder.cache_info()["singleflight_joins"] != 1: + assert time.monotonic() < deadline + time.sleep(0.01) + release_backend.set() + + assert first.result(timeout=2) == [[1.0, 2.0]] + assert second.result(timeout=2) == [[1.0, 2.0]] + + backend.embed.assert_called_once_with(["same"]) + + +def test_backend_failure_is_not_cached(monkeypatch): + _enable_optimization(monkeypatch, ttl_seconds="60") + backend = _backend() + backend.embed.side_effect = [ValueError("temporary"), [[1.0, 2.0]]] + embedder = CachingEmbedder(backend) + + with pytest.raises(ValueError, match="temporary"): + embedder.embed(["same"]) + + assert embedder.embed(["same"]) == [[1.0, 2.0]] + assert backend.embed.call_count == 2 + + +def test_factory_wraps_remote_embedder_when_enabled(monkeypatch): + _enable_optimization(monkeypatch) + backend = _backend() + monkeypatch.setitem(EmbedderFactory.backend_to_class, "ollama", lambda _config: backend) + config = EmbedderConfigFactory.model_validate( + { + "backend": "ollama", + "config": { + "model_name_or_path": "cache-test-model", + "api_base": "http://cache-test.invalid", + }, + } + ) + + embedder = EmbedderFactory.from_config(config) + + assert isinstance(embedder, CachingEmbedder) + assert embedder.config is backend.config From 372861b7cb4af2c518e5087e746dcbeff222b67d Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:04:18 +0800 Subject: [PATCH 14/33] fix: summarize cosine reranker logs --- src/memos/reranker/cosine_local.py | 10 +++++++- tests/reranker/test_local_rerankers.py | 33 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/memos/reranker/cosine_local.py b/src/memos/reranker/cosine_local.py index 318cd744a..140074b1d 100644 --- a/src/memos/reranker/cosine_local.py +++ b/src/memos/reranker/cosine_local.py @@ -98,5 +98,13 @@ def get_weight(it: TextualMemoryItem) -> float: chosen = {it.id for it, _ in top_items} remain = [(it, -1.0) for it in graph_results if it.id not in chosen] top_items.extend(remain[: top_k - len(top_items)]) - logger.info(f"CosineLocalReranker rerank result: {top_items[:1]}") + top_score = round(top_items[0][1], 6) if top_items else None + logger.info( + "CosineLocalReranker rerank result: input_count=%s embedded_count=%s " + "output_count=%s top_score=%s", + len(graph_results), + len(items_with_emb), + len(top_items), + top_score, + ) return top_items diff --git a/tests/reranker/test_local_rerankers.py b/tests/reranker/test_local_rerankers.py index cf6a1fb04..0bb157db0 100644 --- a/tests/reranker/test_local_rerankers.py +++ b/tests/reranker/test_local_rerankers.py @@ -1,3 +1,5 @@ +import logging + from memos.memories.textual.item import TextualMemoryItem, TreeNodeTextualMemoryMetadata from memos.reranker.cosine_local import CosineLocalReranker from memos.reranker.noop import NoopReranker @@ -77,3 +79,34 @@ def test_cosine_local_reranker_fills_missing_embeddings_with_negative_score(): assert ranked[0][0] == embedded assert ranked[1] == (missing, -1.0) + + +def test_cosine_local_reranker_logs_summary_without_candidate_payload(caplog): + private_memory = "private candidate payload" + private_embedding_value = 0.123456789 + item = _memory_item( + "00000000-0000-0000-0000-000000000001", + private_memory, + embedding=[private_embedding_value] * 128, + ) + + with caplog.at_level(logging.INFO): + CosineLocalReranker().rerank( + "private query", + [item], + top_k=1, + query_embedding=[private_embedding_value] * 128, + ) + + message = next( + record.getMessage() + for record in caplog.records + if "CosineLocalReranker rerank result" in record.getMessage() + ) + assert "input_count=1" in message + assert "embedded_count=1" in message + assert "output_count=1" in message + assert "top_score=" in message + assert private_memory not in message + assert str(private_embedding_value) not in message + assert "embedding" not in message From 1d956685c4fa697f5b563e1e9d89caf559de8fd7 Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:17:42 +0800 Subject: [PATCH 15/33] perf: optimize PolarDB retrieval round trips --- src/memos/graph_dbs/polardb.py | 71 +++++++++++-- .../tree_text_memory/retrieve/recall.py | 100 +++++++++++++++++- tests/graph_dbs/test_search_return_fields.py | 37 +++++++ tests/memories/textual/test_tree_retriever.py | 56 ++++++++++ 4 files changed, 252 insertions(+), 12 deletions(-) diff --git a/src/memos/graph_dbs/polardb.py b/src/memos/graph_dbs/polardb.py index 33a79aa75..bf74fbb8b 100644 --- a/src/memos/graph_dbs/polardb.py +++ b/src/memos/graph_dbs/polardb.py @@ -1,4 +1,5 @@ import json +import os import random import textwrap import threading @@ -20,6 +21,29 @@ logger = get_logger(__name__) +def _build_lightweight_return_columns(return_fields: list[str]) -> str: + columns = [] + for field in return_fields: + expression = ( + "embedding" + if field == "embedding" + else f"ag_catalog.agtype_access_operator(properties, '\"{field}\"'::agtype)" + ) + columns.append(f"{expression} AS return_{field}") + return "".join(f",\n {column}" for column in columns) + + +def _decode_lightweight_return_value(value: Any) -> Any: + if hasattr(value, "value"): + value = value.value + if isinstance(value, str): + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return value + return value + + def _compose_node(item: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: node_id = item["id"] memory = item["memory"] @@ -101,6 +125,8 @@ def escape_sql_string(value: str) -> str: class PolarDBGraphDB(BaseGraphDB): """PolarDB-based implementation using Apache AGE graph database extension.""" + supports_lightweight_vector_search = True + @require_python_package( import_name="psycopg2", install_command="pip install psycopg2-binary", @@ -1828,6 +1854,7 @@ def search_by_embedding( filter: dict | None = None, knowledgebase_ids: list[str] | None = None, return_fields: list[str] | None = None, + light_weight_mode: bool = False, **kwargs, ) -> list[dict]: logger.info( @@ -1841,6 +1868,14 @@ def search_by_embedding( knowledgebase_ids, return_fields, ) + validated_return_fields = [ + field for field in self._validate_return_fields(return_fields) if field != "id" + ] + properties_projection = "NULL::agtype AS properties" if light_weight_mode else "properties" + lightweight_return_columns = ( + _build_lightweight_return_columns(validated_return_fields) if light_weight_mode else "" + ) + start_time = time.perf_counter() where_clauses = [] if scope: @@ -1888,10 +1923,10 @@ def search_by_embedding( set hnsw.ef_search = 100;set hnsw.iterative_scan = relaxed_order; WITH t AS ( SELECT id, - properties, + {properties_projection}, timeline, ag_catalog.agtype_access_operator(properties, '"id"'::agtype) AS old_id, - (embedding <=> %s::vector(1024)) AS scope_distance + (embedding <=> %s::vector(1024)) AS scope_distance{lightweight_return_columns} FROM "{self.db_name}_graph"."Memory" {where_clause} ORDER BY scope_distance ASC @@ -1916,7 +1951,19 @@ def search_by_embedding( else: pass - logger.info(" search_by_embedding query: %s", query) + if os.getenv("POLARDB_LOG_EMBEDDING_QUERY", "false").lower() == "true": + logger.info(" search_by_embedding query: %s", query) + else: + logger.info( + "search_by_embedding query omitted query_len=%d vector_dim=%d " + "user_name=%s top_k=%s scope=%s status=%s", + len(query), + len(vector) if vector else 0, + user_name, + top_k, + scope, + status, + ) with self._get_connection() as conn, conn.cursor() as cursor: if params: @@ -1926,8 +1973,11 @@ def search_by_embedding( results = cursor.fetchall() output = [] for row in results: - if len(row) < 5: - logger.warning(f"Row has {len(row)} columns, expected 5. Row: {row}") + expected_columns = 6 + len(validated_return_fields) if light_weight_mode else 5 + if len(row) < expected_columns: + logger.warning( + "Row has %d columns, expected at least %d", len(row), expected_columns + ) continue oldid = row[3] # old_id score = row[4] # scope @@ -1938,9 +1988,16 @@ def search_by_embedding( score_val = (score_val + 1) / 2 # align to neo4j, Normalized Cosine Score if threshold is None or score_val >= threshold: item = {"id": id_val, "score": score_val} - if return_fields: + if light_weight_mode: + for offset, field in enumerate(validated_return_fields, start=5): + item[field] = _decode_lightweight_return_value(row[offset]) + elif validated_return_fields: properties = row[1] # properties column - item.update(self._extract_fields_from_properties(properties, return_fields)) + item.update( + self._extract_fields_from_properties( + properties, validated_return_fields + ) + ) output.append(item) elapsed_time = (time.perf_counter() - start_time) * 1000.0 logger.info( diff --git a/src/memos/memories/textual/tree_text_memory/retrieve/recall.py b/src/memos/memories/textual/tree_text_memory/retrieve/recall.py index dd90b8932..416fb7014 100644 --- a/src/memos/memories/textual/tree_text_memory/retrieve/recall.py +++ b/src/memos/memories/textual/tree_text_memory/retrieve/recall.py @@ -1,4 +1,5 @@ import concurrent.futures +import os from memos.context.context import ContextThreadPoolExecutor from memos.embedders.factory import OllamaEmbedder @@ -11,6 +12,49 @@ logger = get_logger(__name__) +_POLARDB_LIGHTWEIGHT_SEARCH_ENV = "MEMOS_POLARDB_LIGHTWEIGHT_SEARCH_ENABLED" +_LIGHTWEIGHT_VECTOR_RETURN_FIELDS = ( + "memory", + "memory_type", + "user_name", + "user_id", + "session_id", + "status", + "is_fast", + "evolve_to", + "version", + "history", + "working_binding", + "type", + "key", + "confidence", + "source", + "tags", + "visibility", + "updated_at", + "info", + "internal_info", + "message_ids", + "covered_history", + "sources", + "created_at", + "usage", + "background", + "file_ids", + "event_time", + "event_location", + "event_roles", + "preference_type", + "dialog_id", + "original_text", + "preference", + "mem_cube_id", +) + + +def _env_flag_enabled(name: str) -> bool: + return os.getenv(name, "false").strip().lower() in {"1", "true", "yes", "on"} + class GraphMemoryRetriever: """ @@ -340,7 +384,21 @@ def _vector_recall( if not query_embedding: return [] + use_lightweight_search = ( + _env_flag_enabled(_POLARDB_LIGHTWEIGHT_SEARCH_ENV) + and getattr(self.graph_store, "supports_lightweight_vector_search", False) is True + ) + lightweight_return_fields = list(_LIGHTWEIGHT_VECTOR_RETURN_FIELDS) + if self.include_embedding: + lightweight_return_fields.append("embedding") + def search_single(vec, search_priority=None, search_filter=None): + search_kwargs = {} + if use_lightweight_search: + search_kwargs = { + "light_weight_mode": True, + "return_fields": lightweight_return_fields, + } return ( self.graph_store.search_by_embedding( vector=vec, @@ -351,6 +409,7 @@ def search_single(vec, search_priority=None, search_filter=None): search_filter=search_priority, filter=search_filter, user_name=user_name, + **search_kwargs, ) or [] ) @@ -394,17 +453,32 @@ def search_path_b(): return [] # merge and deduplicate, keeping highest score per ID - id_to_score = {} + id_to_hit = {} for r in all_hits: rid = r.get("id") if rid: rid = str(rid).strip("\"'") score = r.get("score", 0.0) - if rid not in id_to_score or score > id_to_score[rid]: - id_to_score[rid] = score + if rid not in id_to_hit or score > id_to_hit[rid].get("score", 0.0): + id_to_hit[rid] = {**r, "id": rid, "score": score} # Sort IDs by score (descending) to preserve ranking - sorted_ids = sorted(id_to_score.keys(), key=lambda x: id_to_score[x], reverse=True) + sorted_ids = sorted( + id_to_hit.keys(), + key=lambda result_id: id_to_hit[result_id].get("score", 0.0), + reverse=True, + ) + + if use_lightweight_search: + lightweight_hits = [id_to_hit[result_id] for result_id in sorted_ids] + if all( + hit.get("memory") is not None and hit.get("memory_type") is not None + for hit in lightweight_hits + ): + return [self._memory_item_from_vector_hit(hit) for hit in lightweight_hits] + logger.warning( + "Lightweight vector search returned incomplete fields; falling back to get_nodes" + ) node_dicts = ( self.graph_store.get_nodes( @@ -434,11 +508,27 @@ def search_path_b(): # Inject similarity score as relativity if "metadata" not in node: node["metadata"] = {} - node["metadata"]["relativity"] = id_to_score.get(rid, 0.0) + node["metadata"]["relativity"] = id_to_hit.get(rid, {}).get("score", 0.0) ordered_nodes.append(node) return [TextualMemoryItem.from_dict(n) for n in ordered_nodes] + @staticmethod + def _memory_item_from_vector_hit(hit: dict) -> TextualMemoryItem: + metadata = { + key: value + for key, value in hit.items() + if key not in {"id", "memory", "score"} and value is not None + } + metadata["relativity"] = hit.get("score", 0.0) + return TextualMemoryItem.from_dict( + { + "id": hit["id"], + "memory": hit["memory"], + "metadata": metadata, + } + ) + def _bm25_recall( self, query: str, diff --git a/tests/graph_dbs/test_search_return_fields.py b/tests/graph_dbs/test_search_return_fields.py index fc95d5a81..1ed0f6173 100644 --- a/tests/graph_dbs/test_search_return_fields.py +++ b/tests/graph_dbs/test_search_return_fields.py @@ -227,6 +227,43 @@ def fake_connection(): assert "n.memory_type = 'DreamDiary'" in query assert ids == ["node-1"] + def test_lightweight_embedding_search_projects_only_requested_fields(self, polardb_instance): + polardb_instance.db_name = "test_db" + polardb_instance.config = {"user_name": "default_user"} + polardb_instance._build_user_name_and_kb_ids_conditions_sql = MagicMock(return_value=[]) + polardb_instance._build_filter_conditions_sql = MagicMock(return_value=[]) + + cursor = MagicMock() + cursor.fetchall.return_value = [ + (1, None, None, '"node-1"', 0.2, '"hello"', '["tag-a"]', 0.8) + ] + conn = MagicMock() + conn.cursor.return_value.__enter__.return_value = cursor + + @contextmanager + def fake_connection(): + yield conn + + polardb_instance._get_connection = fake_connection + + results = polardb_instance.search_by_embedding( + vector=[0.1] * 1024, + user_name="cube-a", + return_fields=["memory", "tags"], + light_weight_mode=True, + ) + + query = cursor.execute.call_args[0][0] + assert "NULL::agtype AS properties" in query + assert "AS return_memory" in query + assert "AS return_tags" in query + assert results == [ + {"id": "node-1", "score": pytest.approx(0.6), "memory": "hello", "tags": ["tag-a"]} + ] + + def test_polardb_declares_lightweight_vector_search_support(self, polardb_instance): + assert polardb_instance.supports_lightweight_vector_search is True + class TestFieldNameValidation: """Tests for _validate_return_fields injection prevention.""" diff --git a/tests/memories/textual/test_tree_retriever.py b/tests/memories/textual/test_tree_retriever.py index 5f97c911a..bc4adee64 100644 --- a/tests/memories/textual/test_tree_retriever.py +++ b/tests/memories/textual/test_tree_retriever.py @@ -80,6 +80,62 @@ def test_vector_recall_combines_and_dedups(retriever, mock_graph_store): assert all(isinstance(r, TextualMemoryItem) for r in results) +def test_vector_recall_uses_lightweight_hits_without_get_nodes( + retriever, mock_graph_store, monkeypatch +): + monkeypatch.setenv("MEMOS_POLARDB_LIGHTWEIGHT_SEARCH_ENABLED", "true") + mock_graph_store.supports_lightweight_vector_search = True + node_id = str(uuid.uuid4()) + mock_graph_store.search_by_embedding.return_value = [ + { + "id": node_id, + "score": 0.91, + "memory": "remembered content", + "memory_type": "LongTermMemory", + "user_name": "cube-a", + "key": "topic", + "tags": ["tag-a"], + } + ] + + results = retriever._vector_recall([[0.1] * 5], "LongTermMemory", top_k=5, user_name="cube-a") + + assert len(results) == 1 + assert results[0].id == node_id + assert results[0].memory == "remembered content" + assert results[0].metadata.memory_type == "LongTermMemory" + assert results[0].metadata.user_name == "cube-a" + assert results[0].metadata.relativity == pytest.approx(0.91) + mock_graph_store.get_nodes.assert_not_called() + search_kwargs = mock_graph_store.search_by_embedding.call_args.kwargs + assert search_kwargs["light_weight_mode"] is True + assert "memory" in search_kwargs["return_fields"] + assert "memory_type" in search_kwargs["return_fields"] + assert "user_name" in search_kwargs["return_fields"] + assert "embedding" not in search_kwargs["return_fields"] + + +def test_vector_recall_falls_back_when_lightweight_hit_lacks_required_fields( + retriever, mock_graph_store, monkeypatch +): + monkeypatch.setenv("MEMOS_POLARDB_LIGHTWEIGHT_SEARCH_ENABLED", "true") + mock_graph_store.supports_lightweight_vector_search = True + node_id = str(uuid.uuid4()) + mock_graph_store.search_by_embedding.return_value = [{"id": node_id, "score": 0.8}] + mock_graph_store.get_nodes.return_value = [ + { + "id": node_id, + "memory": "fallback content", + "metadata": {"memory_type": "LongTermMemory"}, + } + ] + + results = retriever._vector_recall([[0.1] * 5], "LongTermMemory", top_k=5) + + assert results[0].memory == "fallback content" + mock_graph_store.get_nodes.assert_called_once() + + def test_retrieve_merges_graph_and_vector(retriever, mock_graph_store): parsed_goal = ParsedTaskGoal(keys=["k"], tags=["t"]) From 9841b87287d58fe67facf861572f6bdf2b6649d1 Mon Sep 17 00:00:00 2001 From: Wenqiang Wei <46308778+endxxxx@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:19:43 +0800 Subject: [PATCH 16/33] perf: prune MMR candidates by memory bucket --- src/memos/api/handlers/search_handler.py | 89 ++++++++++++++++++++ tests/api/test_mmr_candidate_pruning.py | 100 +++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 tests/api/test_mmr_candidate_pruning.py diff --git a/src/memos/api/handlers/search_handler.py b/src/memos/api/handlers/search_handler.py index 2f170a8a0..363e0b253 100644 --- a/src/memos/api/handlers/search_handler.py +++ b/src/memos/api/handlers/search_handler.py @@ -32,6 +32,8 @@ _ENV_CONTEXT_RECALL = "MEMOS_DREAM_CONTEXT_RECALL" _ENV_CONTEXT_RECALL_TOP_K = "MEMOS_DREAM_CONTEXT_RECALL_TOP_K" _DEFAULT_CONTEXT_RECALL_TOP_K = 2 +_ENV_MMR_CANDIDATE_PRUNING = "MEMOS_MMR_CANDIDATE_PRUNING_ENABLED" +_MMR_CANDIDATE_MULTIPLIER = 2 def _env_enabled(name: str, default: str = "off") -> bool: @@ -352,6 +354,12 @@ def _mmr_dedup_text_memories( if not text_buckets and not pref_buckets: return results + self._prune_mmr_candidates_by_bucket( + results, + text_top_k=text_top_k, + pref_top_k=pref_top_k, + ) + # Flatten all memories with their type and scores # flat structure: (memory_type, bucket_idx, mem, score) flat: list[tuple[str, int, dict[str, Any], float]] = [] @@ -556,6 +564,87 @@ def _mmr_dedup_text_memories( return results + def _prune_mmr_candidates_by_bucket( + self, + results: dict[str, Any], + *, + text_top_k: int, + pref_top_k: int, + ) -> dict[str, Any]: + """Keep at most twice the final quota for each memory type in each bucket.""" + if not _env_enabled(_ENV_MMR_CANDIDATE_PRUNING, "off"): + return results + + total_before = 0 + total_after = 0 + bucket_counts: list[tuple[int, int]] = [] + + for result_key, target_top_k in ( + ("text_mem", text_top_k), + ("pref_mem", pref_top_k), + ): + buckets = results.get(result_key) + if not isinstance(buckets, list): + continue + + candidate_limit = max(0, int(target_top_k)) * _MMR_CANDIDATE_MULTIPLIER + for bucket in buckets: + memories = bucket.get("memories") if isinstance(bucket, dict) else None + if not isinstance(memories, list): + continue + + before_count = len(memories) + total_before += before_count + memories_by_type: dict[str, list[dict[str, Any]]] = {} + for memory in memories: + if not isinstance(memory, dict): + continue + metadata = memory.get("metadata") + memory_type = ( + metadata.get("memory_type") if isinstance(metadata, dict) else None + ) + memories_by_type.setdefault(str(memory_type or result_key), []).append(memory) + + selected: list[dict[str, Any]] = [] + if candidate_limit > 0: + for typed_memories in memories_by_type.values(): + selected.extend( + sorted( + typed_memories, + key=self._mmr_candidate_score, + reverse=True, + )[:candidate_limit] + ) + selected.sort(key=self._mmr_candidate_score, reverse=True) + + bucket["memories"] = selected + if "total_nodes" in bucket: + bucket["total_nodes"] = len(selected) + total_after += len(selected) + bucket_counts.append((before_count, len(selected))) + + self.logger.info( + "[SearchHandler] MMR candidate pruning: multiplier=%s before=%s after=%s " + "dropped=%s bucket_counts=%s", + _MMR_CANDIDATE_MULTIPLIER, + total_before, + total_after, + total_before - total_after, + bucket_counts, + ) + return results + + @staticmethod + def _mmr_candidate_score(memory: dict[str, Any]) -> float: + metadata = memory.get("metadata") + if not isinstance(metadata, dict): + return 0.0 + score = metadata.get("score", metadata.get("relativity", 0.0)) + try: + return float(score or 0.0) + except (TypeError, ValueError): + return 0.0 + @staticmethod def _is_unrelated( index: int, diff --git a/tests/api/test_mmr_candidate_pruning.py b/tests/api/test_mmr_candidate_pruning.py new file mode 100644 index 000000000..f25734e08 --- /dev/null +++ b/tests/api/test_mmr_candidate_pruning.py @@ -0,0 +1,100 @@ +from unittest.mock import Mock + +from memos.api.handlers.search_handler import SearchHandler + + +def _memory(memory_id: str, memory_type: str, score: float) -> dict: + return { + "id": memory_id, + "memory": memory_id, + "metadata": {"memory_type": memory_type, "relativity": score}, + } + + +def _bucket(*memories: dict) -> dict: + return {"cube_id": "cube-a", "memories": list(memories), "total_nodes": len(memories)} + + +def test_mmr_candidate_pruning_is_disabled_by_default(monkeypatch): + monkeypatch.delenv("MEMOS_MMR_CANDIDATE_PRUNING_ENABLED", raising=False) + handler = SearchHandler.__new__(SearchHandler) + handler.logger = Mock() + results = { + "text_mem": [ + _bucket( + _memory("text-1", "LongTermMemory", 0.9), + _memory("text-2", "LongTermMemory", 0.8), + _memory("text-3", "LongTermMemory", 0.7), + ) + ] + } + + handler._prune_mmr_candidates_by_bucket(results, text_top_k=1, pref_top_k=1) + + assert [item["id"] for item in results["text_mem"][0]["memories"]] == [ + "text-1", + "text-2", + "text-3", + ] + + +def test_mmr_candidate_pruning_limits_each_memory_type_and_bucket(monkeypatch): + monkeypatch.setenv("MEMOS_MMR_CANDIDATE_PRUNING_ENABLED", "true") + handler = SearchHandler.__new__(SearchHandler) + handler.logger = Mock() + results = { + "text_mem": [ + _bucket( + _memory("long-1", "LongTermMemory", 0.90), + _memory("long-2", "LongTermMemory", 0.70), + _memory("long-3", "LongTermMemory", 0.50), + _memory("user-1", "UserMemory", 0.85), + _memory("user-2", "UserMemory", 0.65), + _memory("user-3", "UserMemory", 0.45), + ) + ], + "pref_mem": [ + _bucket( + _memory("pref-1", "PreferenceMemory", 0.88), + _memory("pref-2", "PreferenceMemory", 0.68), + _memory("pref-3", "PreferenceMemory", 0.48), + ) + ], + } + + handler._prune_mmr_candidates_by_bucket(results, text_top_k=1, pref_top_k=1) + + assert {item["id"] for item in results["text_mem"][0]["memories"]} == { + "long-1", + "long-2", + "user-1", + "user-2", + } + assert [item["id"] for item in results["pref_mem"][0]["memories"]] == [ + "pref-1", + "pref-2", + ] + assert results["text_mem"][0]["total_nodes"] == 4 + assert results["pref_mem"][0]["total_nodes"] == 2 + + +def test_mmr_dedup_prunes_before_embedding_extraction(monkeypatch): + monkeypatch.setenv("MEMOS_MMR_CANDIDATE_PRUNING_ENABLED", "true") + handler = SearchHandler.__new__(SearchHandler) + handler.logger = Mock() + handler._extract_embeddings = Mock(return_value=[[1.0, 0.0], [0.0, 1.0]]) + results = { + "text_mem": [ + _bucket( + _memory("text-1", "LongTermMemory", 0.9), + _memory("text-2", "LongTermMemory", 0.8), + _memory("text-3", "LongTermMemory", 0.7), + ) + ], + "pref_mem": [], + } + + handler._mmr_dedup_text_memories(results, text_top_k=1, pref_top_k=1) + + embedded_memories = handler._extract_embeddings.call_args.args[0] + assert [item["id"] for item in embedded_memories] == ["text-1", "text-2"] From 976d1af3fcf8f7e713b373b27f4d3e30af76aa64 Mon Sep 17 00:00:00 2001 From: Memtensor-AI Date: Thu, 23 Jul 2026 17:28:17 +0800 Subject: [PATCH 17/33] Fix #1611: classifyTopic fails when summarizer uses Anthropic endpoint (Kimi Code) (#2133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(openclaw): route classifyTopic + arbitrateTopicSplit per provider (#1611) `Summarizer.classifyTopic` and `Summarizer.arbitrateTopicSplit` always dispatched to the OpenAI implementation regardless of the summarizer's configured provider. When configured against an Anthropic-only endpoint (e.g. Kimi Code's `/coding/v1/messages`), every call returned 404 because the OpenAI helper appended `/chat/completions` to a URL that does not exist — wasting tokens, polluting logs, adding event-loop latency. Fix (Option A from the issue): - Add native classifyTopic{Anthropic,Gemini,Bedrock} and arbitrateTopicSplit{Anthropic,Gemini,Bedrock} in the three provider files, mirroring the shape of the existing judgeNewTopic* helpers. - Re-export TOPIC_CLASSIFIER_PROMPT / TOPIC_ARBITRATION_PROMPT from openai.ts so all providers share prompt strings and parseTopicClassifyResult remains the single JSON parser — no behavioral drift across providers. - Rewire callTopicClassifier / callTopicArbitration switch statements so the anthropic / gemini / bedrock arms route to their native implementations instead of falling through to the OpenAI helpers. - Bedrock helpers require cfg.endpoint (throws `Bedrock topic-classifier|arbitration requires 'endpoint'`) matching every other Bedrock helper in the file. - Mirror every edit into packages/memos-core/src/ingest/providers/ so the byte-similar sibling tree cannot silently regress the bug after the next sync. Tests: new tests/topic-classifier-dispatch.test.ts (8 cases) stubs globalThis.fetch and asserts each provider hits the correct URL + body shape; includes a regression case where an Anthropic 404 surfaces an `Anthropic topic-classifier failed` error rather than the old `OpenAI topic-classifier failed` — the exact string from issue #1611. TDD baseline: 7/8 fail against buggy code. Post-fix: 8/8 pass. Existing tests/topic-judge-minimax-1315.test.ts (5/5) continues to pass. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(openclaw): harden topic-classifier/arbitrator providers per OCR review Address code-review findings on the topic-classifier + arbitrator dispatch helpers added in #2133 (issue #1611). Applied to both apps/memos-local-openclaw and packages/memos-core (byte-similar mirrors). Correctness / security fixes: - anthropic: validate cfg.apiKey up front instead of silently sending ""; move cfg.headers spread BEFORE x-api-key + anthropic-version so a misconfigured header cannot silently overwrite the credential - anthropic: guard content parsing — treat missing/non-array json.content as [] to avoid TypeError on unexpected API payloads - anthropic: reduce arbitrateTopicSplit max_tokens from 60 to 10 to match arbitrateTopicSplitOpenAI (single-word NEW/SAME reply) - gemini: validate cfg.apiKey up front; construct URL via new URL() + URLSearchParams so a caller-supplied endpoint that already contains a query string ("?version=v1beta") does not produce malformed "…?version=v1beta?key=…" - bedrock: emit warn log when arbitrateTopicSplit sees empty or unexpected text so silent bias toward SAME becomes visible at normal log levels (production usually suppresses debug) Intentionally not applied (out-of-scope refactors that would touch pre-existing helpers not in this PR): DRY-extract shared low-level callers for anthropic/gemini/bedrock (findings #4/#7/#10/#14) and the DEFAULT_BEDROCK_MODEL constant (finding #11). Existing tests unchanged: tests/topic-classifier-dispatch.test.ts (8/8) and tests/topic-judge-minimax-1315.test.ts (5/5) still pass. The new guards use apiKey values already provided by every test case, so no test edits were required. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(openclaw): address remaining OCR round-2 findings on topic-classifier providers Round 1 (commit 646c28a1) did not fully converge OCR review. Round 2 targets the 2 repeated findings and 10 newly detected ones without expanding scope beyond the classifier/arbitration functions added by PR #2133. Bedrock (packages/memos-core + apps/memos-local-openclaw): - Fix TRUE MISS from Round 1: `arbitrateTopicSplitBedrock` was still using `maxTokens: 60` while Anthropic's arbitrator was already lowered to 10. Aligned to `maxTokens: 10`. - Introduce `DEFAULT_BEDROCK_TOPIC_MODEL` constant scoped to the two new functions only (`classifyTopicBedrock`, `arbitrateTopicSplitBedrock`). Pre-existing helpers (`judgeDedupBedrock`, `summarizeBedrock`, etc.) keep their inline default to stay out of scope. - Extract `bedrockConverseTopic()` shared transport used by the two new functions to eliminate the DRY duplication OCR flagged. Gemini (packages/memos-core + apps/memos-local-openclaw): - Add empty/unexpected-response `log.warn` branches to `arbitrateTopicSplitGemini`, mirroring the Bedrock arbitrator's defensive handling (Round 1 covered Bedrock only). - Extract `callGeminiTopic()` shared transport used by the two new functions to eliminate the DRY duplication OCR flagged. - Rejected escalating the empty-response case to `throw`: throwing would cascade through `Summarizer.tryChain` and burn tokens on every fallback; warn+default-to-SAME is the safer choice for a boundary detector. Anthropic (packages/memos-core + apps/memos-local-openclaw): - Extract `callAnthropicMessagesTopic()` shared transport used by the two new functions to eliminate the DRY duplication OCR flagged. Header spread order is preserved verbatim: `...cfg.headers` comes BEFORE the credential and `anthropic-version` so user-supplied headers cannot override them (this was the Round 1 security fix — do not undo). Pre-existing helpers in each provider are intentionally untouched to keep the diff minimum-scoped to PR #2133's classifier/arbitrator additions. Verification (executed in apps/memos-local-openclaw): - `./node_modules/.bin/vitest run tests/topic-classifier-dispatch.test.ts \\ tests/topic-judge-minimax-1315.test.ts` → 13/13 passed - `./node_modules/.bin/tsc --noEmit` on the 3 modified provider files → 0 errors - All 6 files (3 apps + 3 packages/memos-core mirrors) are byte-identical --------- Co-authored-by: MemOS AutoDev Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/ingest/providers/anthropic.ts | 97 +++++++ .../src/ingest/providers/bedrock.ts | 103 +++++++ .../src/ingest/providers/gemini.ts | 100 +++++++ .../src/ingest/providers/index.ts | 14 +- .../src/ingest/providers/openai.ts | 4 +- .../tests/topic-classifier-dispatch.test.ts | 270 ++++++++++++++++++ .../src/ingest/providers/anthropic.ts | 97 +++++++ .../src/ingest/providers/bedrock.ts | 103 +++++++ .../memos-core/src/ingest/providers/gemini.ts | 100 +++++++ .../memos-core/src/ingest/providers/index.ts | 14 +- .../memos-core/src/ingest/providers/openai.ts | 4 +- 11 files changed, 892 insertions(+), 14 deletions(-) create mode 100644 apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts diff --git a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts index e9845d334..72d23630a 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts @@ -336,6 +336,103 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Anthropic Messages transport for the topic classifier + arbitration. +// The prompt strings (TOPIC_CLASSIFIER_PROMPT / TOPIC_ARBITRATION_PROMPT) and +// the parser (parseTopicClassifyResult) are re-imported from openai.ts so all +// providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Anthropic Messages transport for the topic classifier + arbitration. +// Scoped to these two callers only so pre-existing anthropic helpers keep +// their inline implementations (minimum-diff discipline). Header order below +// is deliberate: cfg.headers is spread FIRST so user-supplied headers cannot +// override the credential and required `anthropic-version` that follow. +async function callAnthropicMessagesTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Anthropic ${errorLabel}: apiKey is required`); + const endpoint = cfg.endpoint ?? "https://api.anthropic.com/v1/messages"; + const model = cfg.model ?? "claude-3-haiku-20240307"; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + "x-api-key": cfg.apiKey, + "anthropic-version": "2023-06-01", + }; + + const resp = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + temperature: 0, + system: systemPrompt, + messages: [{ role: "user", content: userContent }], + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Anthropic ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { content?: Array<{ type: string; text: string }> }; + const content = Array.isArray(json?.content) ? json.content : []; + return content.find((c) => c.type === "text")?.text?.trim() ?? ""; +} + +export async function classifyTopicAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callAnthropicMessagesTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callAnthropicMessagesTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupAnthropic( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts index 1fcd0b359..a62640324 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts @@ -344,6 +344,109 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Bedrock Converse transport for the topic classifier + arbitration. +// Shares prompt strings and the JSON parser with openai.ts so all providers +// stay behaviorally identical modulo the wire format. `endpoint` is required +// here, matching every other Bedrock helper in this file. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Default Bedrock model for the topic-classifier / arbitration helpers. +// Scoped to the two functions below to avoid churn in pre-existing helpers. +const DEFAULT_BEDROCK_TOPIC_MODEL = "anthropic.claude-3-haiku-20240307-v1:0"; + +// Shared Converse transport used by the topic classifier and arbitration. +// Only these two callers use it — pre-existing bedrock helpers keep their +// original inline implementations to minimise diff. +async function bedrockConverseTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + const model = cfg.model ?? DEFAULT_BEDROCK_TOPIC_MODEL; + const endpoint = cfg.endpoint; + if (!endpoint) { + throw new Error(`Bedrock ${errorLabel} requires 'endpoint'`); + } + + const url = `${endpoint}/model/${model}/converse`; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + system: [{ text: systemPrompt }], + messages: [{ role: "user", content: [{ text: userContent }] }], + inferenceConfig: { temperature: 0, maxTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Bedrock ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { output?: { message?: { content?: Array<{ text: string }> } } }; + return json.output?.message?.content?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await bedrockConverseTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await bedrockConverseTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Bedrock topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Bedrock topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupBedrock( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts index 3fafe570d..eab5b3d5c 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts @@ -336,6 +336,106 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Gemini generateContent transport for the topic classifier + +// arbitration. Shares prompt strings and the JSON parser with openai.ts so +// all providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Gemini generateContent transport for the topic classifier + +// arbitration. Scoped to these two callers only so pre-existing gemini +// helpers keep their inline URL construction (minimum-diff discipline). +async function callGeminiTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxOutputTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Gemini ${errorLabel}: apiKey is required`); + const model = cfg.model ?? "gemini-1.5-flash"; + const endpoint = + cfg.endpoint ?? + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + + const u = new URL(endpoint); + u.searchParams.set("key", cfg.apiKey); + const url = u.toString(); + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: userContent }] }], + generationConfig: { temperature: 0, maxOutputTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Gemini ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text: string }> } }> }; + return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callGeminiTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callGeminiTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Gemini topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Gemini topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupGemini( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/apps/memos-local-openclaw/src/ingest/providers/index.ts b/apps/memos-local-openclaw/src/ingest/providers/index.ts index c5dd1a85c..972930f9e 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/index.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/index.ts @@ -5,9 +5,9 @@ import { parseJsonOrJson5 } from "../../shared/json5"; import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, classifyTopicOpenAI, arbitrateTopicSplitOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult, parseTopicClassifyResult } from "./openai"; import type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; export type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; -import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic"; -import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini"; -import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock"; +import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic, classifyTopicAnthropic, arbitrateTopicSplitAnthropic } from "./anthropic"; +import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini, classifyTopicGemini, arbitrateTopicSplitGemini } from "./gemini"; +import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock, classifyTopicBedrock, arbitrateTopicSplitBedrock } from "./bedrock"; /** * Resolve a SecretInput (string | SecretRef) to a plain string. @@ -714,9 +714,11 @@ function callTopicClassifier(cfg: SummarizerConfig, taskState: string, newMessag case "voyage": return classifyTopicOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return classifyTopicAnthropic(taskState, newMessage, cfg, log); case "gemini": + return classifyTopicGemini(taskState, newMessage, cfg, log); case "bedrock": - return classifyTopicOpenAI(taskState, newMessage, cfg, log); + return classifyTopicBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } @@ -737,9 +739,11 @@ function callTopicArbitration(cfg: SummarizerConfig, taskState: string, newMessa case "voyage": return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return arbitrateTopicSplitAnthropic(taskState, newMessage, cfg, log); case "gemini": + return arbitrateTopicSplitGemini(taskState, newMessage, cfg, log); case "bedrock": - return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); + return arbitrateTopicSplitBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts index 8289f79ac..decfada30 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts @@ -270,7 +270,7 @@ export interface TopicClassifyResult { reason: string; // may be empty for compact responses } -const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. +export const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0} d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain. Sub-questions, tools, methods, details of current topic = S.`; @@ -318,7 +318,7 @@ export async function classifyTopicOpenAI( return parseTopicClassifyResult(raw, log); } -const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? +export const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW. Reply one word: NEW or SAME`; diff --git a/apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts b/apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts new file mode 100644 index 000000000..51dee4819 --- /dev/null +++ b/apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts @@ -0,0 +1,270 @@ +/** + * Regression test for issue #1611: + * + * `Summarizer.classifyTopic` and `Summarizer.arbitrateTopicSplit` used to + * dispatch to the OpenAI implementation for EVERY provider — including + * `anthropic`, `gemini`, and `bedrock` — because their case arms in the + * `callTopicClassifier` / `callTopicArbitration` switch statements silently + * fell through to `classifyTopicOpenAI` / `arbitrateTopicSplitOpenAI`. + * + * That broke summarizers configured against Anthropic-only endpoints (e.g. + * Kimi Code's `/coding/v1/messages`), where the OpenAI helper's + * `/chat/completions` URL simply doesn't exist and every call 404'd. + * + * These tests stub `globalThis.fetch` and assert that each provider hits the + * transport-appropriate URL with a body shape the target API actually + * accepts. A regression that routed `anthropic` back through the OpenAI + * transport would either hit `/chat/completions` (wrong URL) or emit an + * `OpenAI topic-classifier failed` error string (wrong label) — both are + * asserted below. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Summarizer } from "../src/ingest/providers"; +import type { SummarizerConfig, Logger } from "../src/types"; + +const silentLog: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +interface CapturedRequest { + url: string; + headers: Record; + body: Record; +} + +/** + * Replace global.fetch with a recorder. Each invocation returns a canned + * successful response whose shape matches `provider`. + */ +function installFetchRecorder( + provider: "openai" | "anthropic" | "gemini" | "bedrock", + replyText: string, +): CapturedRequest[] { + const captured: CapturedRequest[] = []; + + const buildResponse = () => { + switch (provider) { + case "openai": + return { choices: [{ message: { content: replyText } }] }; + case "anthropic": + return { content: [{ type: "text", text: replyText }] }; + case "gemini": + return { candidates: [{ content: { parts: [{ text: replyText }] } }] }; + case "bedrock": + return { output: { message: { content: [{ text: replyText }] } } }; + } + }; + + const fakeFetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : {}; + const headers: Record = {}; + if (init?.headers) { + const h = init.headers as Record; + for (const [k, v] of Object.entries(h)) headers[k] = String(v); + } + captured.push({ url: String(url), headers, body }); + return new Response(JSON.stringify(buildResponse()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + vi.stubGlobal("fetch", fakeFetch); + return captured; +} + +function installErrorFetch(status: number, errorBody: string): void { + const fakeFetch = vi.fn(async () => + new Response(errorBody, { status, headers: { "Content-Type": "application/json" } }), + ); + vi.stubGlobal("fetch", fakeFetch); +} + +describe("classifyTopic / arbitrateTopicSplit dispatch by provider (issue #1611)", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("openai_compatible → POSTs /chat/completions with system+user messages", async () => { + const captured = installFetchRecorder("openai", '{"d":"S","c":0.9}'); + const cfg: SummarizerConfig = { + provider: "openai_compatible", + endpoint: "https://api.example.com/v1", + apiKey: "sk-test", + model: "test-model", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("SAME"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe("https://api.example.com/v1/chat/completions"); + expect(captured[0].headers.Authorization).toBe("Bearer sk-test"); + const msgs = captured[0].body.messages as Array<{ role: string; content: string }>; + expect(msgs[0].role).toBe("system"); + expect(msgs[0].content).toContain("Classify if NEW MESSAGE"); + expect(msgs[1].role).toBe("user"); + expect(msgs[1].content).toContain("TASK:\ntask state"); + }); + + it("anthropic → POSTs /v1/messages with x-api-key and system prompt (regression: was hitting /chat/completions)", async () => { + const captured = installFetchRecorder("anthropic", '{"d":"N","c":0.85}'); + const cfg: SummarizerConfig = { + provider: "anthropic", + endpoint: "https://api.kimi.com/coding/v1/messages", + apiKey: "kimi-test", + model: "kimi-for-coding", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("NEW"); + expect(result?.confidence).toBeCloseTo(0.85); + + expect(captured).toHaveLength(1); + // Must NOT contain /chat/completions — that's the pre-fix bug. + expect(captured[0].url).not.toContain("/chat/completions"); + expect(captured[0].url).toBe("https://api.kimi.com/coding/v1/messages"); + expect(captured[0].headers["x-api-key"]).toBe("kimi-test"); + expect(captured[0].headers["anthropic-version"]).toBe("2023-06-01"); + expect(captured[0].body.system).toContain("Classify if NEW MESSAGE"); + expect(captured[0].body.model).toBe("kimi-for-coding"); + const anthropicMessages = captured[0].body.messages as Array<{ role: string; content: string }>; + expect(anthropicMessages[0].role).toBe("user"); + expect(anthropicMessages[0].content).toContain("TASK:\ntask state"); + }); + + it("gemini → POSTs :generateContent with systemInstruction and apiKey query param", async () => { + const captured = installFetchRecorder("gemini", '{"d":"S","c":0.7}'); + const cfg: SummarizerConfig = { + provider: "gemini", + apiKey: "gemini-test", + model: "gemini-1.5-flash", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("SAME"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toContain(":generateContent?key=gemini-test"); + expect(captured[0].url).toContain("gemini-1.5-flash"); + // Body must use Gemini's structured shape, not OpenAI's. + expect(captured[0].body.messages).toBeUndefined(); + const sysInstr = captured[0].body.systemInstruction as { parts: Array<{ text: string }> }; + expect(sysInstr.parts[0].text).toContain("Classify if NEW MESSAGE"); + const contents = captured[0].body.contents as Array<{ parts: Array<{ text: string }> }>; + expect(contents[0].parts[0].text).toContain("TASK:\ntask state"); + }); + + it("bedrock → POSTs /model//converse with system[] and messages[]", async () => { + const captured = installFetchRecorder("bedrock", '{"d":"N","c":0.6}'); + const cfg: SummarizerConfig = { + provider: "bedrock", + endpoint: "https://bedrock-runtime.us-east-1.amazonaws.com", + apiKey: "aws-test", + model: "anthropic.claude-3-haiku-20240307-v1:0", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("NEW"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/converse", + ); + // Body must use Bedrock Converse shape. + expect(captured[0].body.choices).toBeUndefined(); + const system = captured[0].body.system as Array<{ text: string }>; + expect(system[0].text).toContain("Classify if NEW MESSAGE"); + const bedrockMessages = captured[0].body.messages as Array<{ role: string; content: Array<{ text: string }> }>; + expect(bedrockMessages[0].role).toBe("user"); + expect(bedrockMessages[0].content[0].text).toContain("TASK:\ntask state"); + }); + + it("arbitrateTopicSplit for anthropic → POSTs /v1/messages, returns NEW/SAME", async () => { + const captured = installFetchRecorder("anthropic", "NEW"); + const cfg: SummarizerConfig = { + provider: "anthropic", + endpoint: "https://api.kimi.com/coding/v1/messages", + apiKey: "kimi-test", + model: "kimi-for-coding", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.arbitrateTopicSplit("task", "msg"); + expect(result).toBe("NEW"); + expect(captured[0].url).toBe("https://api.kimi.com/coding/v1/messages"); + expect(captured[0].body.system).toContain("A classifier flagged this message"); + }); + + it("arbitrateTopicSplit for gemini → POSTs :generateContent, returns SAME on non-NEW reply", async () => { + const captured = installFetchRecorder("gemini", "same"); + const cfg: SummarizerConfig = { + provider: "gemini", + apiKey: "gemini-test", + model: "gemini-1.5-flash", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.arbitrateTopicSplit("task", "msg"); + expect(result).toBe("SAME"); + expect(captured[0].url).toContain(":generateContent?key=gemini-test"); + }); + + it("arbitrateTopicSplit for bedrock → POSTs /converse, uses system[] shape", async () => { + const captured = installFetchRecorder("bedrock", "NEW\n"); + const cfg: SummarizerConfig = { + provider: "bedrock", + endpoint: "https://bedrock-runtime.us-east-1.amazonaws.com", + apiKey: "aws-test", + model: "anthropic.claude-3-haiku-20240307-v1:0", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.arbitrateTopicSplit("task", "msg"); + expect(result).toBe("NEW"); + expect(captured[0].url).toContain("/model/anthropic.claude-3-haiku-20240307-v1:0/converse"); + const bsystem = captured[0].body.system as Array<{ text: string }>; + expect(bsystem[0].text).toContain("A classifier flagged this message"); + }); + + it("anthropic 404 surfaces as 'Anthropic topic-classifier failed', not 'OpenAI' — the actual issue #1611 signature", async () => { + installErrorFetch( + 404, + '{"error":{"message":"The requested resource was not found","type":"resource_not_found_error"}}', + ); + const errorMessages: string[] = []; + const captureLog: Logger = { + debug: () => {}, + info: () => {}, + warn: (m) => errorMessages.push(m), + error: (m) => errorMessages.push(m), + }; + const cfg: SummarizerConfig = { + provider: "anthropic", + endpoint: "https://api.kimi.com/coding/v1/messages", + apiKey: "kimi-test", + model: "kimi-for-coding", + }; + const sum = new Summarizer(cfg, captureLog); + + const result = await sum.classifyTopic("state", "msg"); + // No fallback configs, so classifyTopic returns null after logging. + expect(result).toBeNull(); + // The recorded error message must reference the Anthropic transport, not OpenAI. + const combined = errorMessages.join("\n"); + expect(combined).toContain("Anthropic topic-classifier failed"); + expect(combined).not.toContain("OpenAI topic-classifier failed"); + }); +}); diff --git a/packages/memos-core/src/ingest/providers/anthropic.ts b/packages/memos-core/src/ingest/providers/anthropic.ts index e9845d334..72d23630a 100644 --- a/packages/memos-core/src/ingest/providers/anthropic.ts +++ b/packages/memos-core/src/ingest/providers/anthropic.ts @@ -336,6 +336,103 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Anthropic Messages transport for the topic classifier + arbitration. +// The prompt strings (TOPIC_CLASSIFIER_PROMPT / TOPIC_ARBITRATION_PROMPT) and +// the parser (parseTopicClassifyResult) are re-imported from openai.ts so all +// providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Anthropic Messages transport for the topic classifier + arbitration. +// Scoped to these two callers only so pre-existing anthropic helpers keep +// their inline implementations (minimum-diff discipline). Header order below +// is deliberate: cfg.headers is spread FIRST so user-supplied headers cannot +// override the credential and required `anthropic-version` that follow. +async function callAnthropicMessagesTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Anthropic ${errorLabel}: apiKey is required`); + const endpoint = cfg.endpoint ?? "https://api.anthropic.com/v1/messages"; + const model = cfg.model ?? "claude-3-haiku-20240307"; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + "x-api-key": cfg.apiKey, + "anthropic-version": "2023-06-01", + }; + + const resp = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + temperature: 0, + system: systemPrompt, + messages: [{ role: "user", content: userContent }], + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Anthropic ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { content?: Array<{ type: string; text: string }> }; + const content = Array.isArray(json?.content) ? json.content : []; + return content.find((c) => c.type === "text")?.text?.trim() ?? ""; +} + +export async function classifyTopicAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callAnthropicMessagesTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callAnthropicMessagesTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupAnthropic( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/packages/memos-core/src/ingest/providers/bedrock.ts b/packages/memos-core/src/ingest/providers/bedrock.ts index 1fcd0b359..a62640324 100644 --- a/packages/memos-core/src/ingest/providers/bedrock.ts +++ b/packages/memos-core/src/ingest/providers/bedrock.ts @@ -344,6 +344,109 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Bedrock Converse transport for the topic classifier + arbitration. +// Shares prompt strings and the JSON parser with openai.ts so all providers +// stay behaviorally identical modulo the wire format. `endpoint` is required +// here, matching every other Bedrock helper in this file. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Default Bedrock model for the topic-classifier / arbitration helpers. +// Scoped to the two functions below to avoid churn in pre-existing helpers. +const DEFAULT_BEDROCK_TOPIC_MODEL = "anthropic.claude-3-haiku-20240307-v1:0"; + +// Shared Converse transport used by the topic classifier and arbitration. +// Only these two callers use it — pre-existing bedrock helpers keep their +// original inline implementations to minimise diff. +async function bedrockConverseTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + const model = cfg.model ?? DEFAULT_BEDROCK_TOPIC_MODEL; + const endpoint = cfg.endpoint; + if (!endpoint) { + throw new Error(`Bedrock ${errorLabel} requires 'endpoint'`); + } + + const url = `${endpoint}/model/${model}/converse`; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + system: [{ text: systemPrompt }], + messages: [{ role: "user", content: [{ text: userContent }] }], + inferenceConfig: { temperature: 0, maxTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Bedrock ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { output?: { message?: { content?: Array<{ text: string }> } } }; + return json.output?.message?.content?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await bedrockConverseTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await bedrockConverseTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Bedrock topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Bedrock topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupBedrock( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/packages/memos-core/src/ingest/providers/gemini.ts b/packages/memos-core/src/ingest/providers/gemini.ts index 3fafe570d..eab5b3d5c 100644 --- a/packages/memos-core/src/ingest/providers/gemini.ts +++ b/packages/memos-core/src/ingest/providers/gemini.ts @@ -336,6 +336,106 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Gemini generateContent transport for the topic classifier + +// arbitration. Shares prompt strings and the JSON parser with openai.ts so +// all providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Gemini generateContent transport for the topic classifier + +// arbitration. Scoped to these two callers only so pre-existing gemini +// helpers keep their inline URL construction (minimum-diff discipline). +async function callGeminiTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxOutputTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Gemini ${errorLabel}: apiKey is required`); + const model = cfg.model ?? "gemini-1.5-flash"; + const endpoint = + cfg.endpoint ?? + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + + const u = new URL(endpoint); + u.searchParams.set("key", cfg.apiKey); + const url = u.toString(); + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: userContent }] }], + generationConfig: { temperature: 0, maxOutputTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Gemini ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text: string }> } }> }; + return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callGeminiTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callGeminiTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Gemini topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Gemini topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupGemini( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/packages/memos-core/src/ingest/providers/index.ts b/packages/memos-core/src/ingest/providers/index.ts index b08818520..005edc23a 100644 --- a/packages/memos-core/src/ingest/providers/index.ts +++ b/packages/memos-core/src/ingest/providers/index.ts @@ -4,9 +4,9 @@ import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../ import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, classifyTopicOpenAI, arbitrateTopicSplitOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult, parseTopicClassifyResult } from "./openai"; import type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; export type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; -import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic"; -import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini"; -import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock"; +import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic, classifyTopicAnthropic, arbitrateTopicSplitAnthropic } from "./anthropic"; +import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini, classifyTopicGemini, arbitrateTopicSplitGemini } from "./gemini"; +import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock, classifyTopicBedrock, arbitrateTopicSplitBedrock } from "./bedrock"; /** * Resolve a SecretInput (string | SecretRef) to a plain string. @@ -713,9 +713,11 @@ function callTopicClassifier(cfg: SummarizerConfig, taskState: string, newMessag case "voyage": return classifyTopicOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return classifyTopicAnthropic(taskState, newMessage, cfg, log); case "gemini": + return classifyTopicGemini(taskState, newMessage, cfg, log); case "bedrock": - return classifyTopicOpenAI(taskState, newMessage, cfg, log); + return classifyTopicBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } @@ -736,9 +738,11 @@ function callTopicArbitration(cfg: SummarizerConfig, taskState: string, newMessa case "voyage": return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return arbitrateTopicSplitAnthropic(taskState, newMessage, cfg, log); case "gemini": + return arbitrateTopicSplitGemini(taskState, newMessage, cfg, log); case "bedrock": - return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); + return arbitrateTopicSplitBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } diff --git a/packages/memos-core/src/ingest/providers/openai.ts b/packages/memos-core/src/ingest/providers/openai.ts index 825e2131d..45eeb808f 100644 --- a/packages/memos-core/src/ingest/providers/openai.ts +++ b/packages/memos-core/src/ingest/providers/openai.ts @@ -262,7 +262,7 @@ export interface TopicClassifyResult { reason: string; // may be empty for compact responses } -const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. +export const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0} d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain. Sub-questions, tools, methods, details of current topic = S.`; @@ -310,7 +310,7 @@ export async function classifyTopicOpenAI( return parseTopicClassifyResult(raw, log); } -const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? +export const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW. Reply one word: NEW or SAME`; From 0e78a00ca9b793de3ca01009fad46fab55f49851 Mon Sep 17 00:00:00 2001 From: Memtensor-AI Date: Thu, 23 Jul 2026 17:28:50 +0800 Subject: [PATCH 18/33] =?UTF-8?q?Fix=20#2131:=20[Bug]=20memos-local-plugin?= =?UTF-8?q?=20:=20chunks=20table=20never=20created=20=E2=80=94=20causes=20?= =?UTF-8?q?viewer=20dashboard=20=20(#2132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: viewer dashboard drifts to zero after a namespace flip (#2131) The viewer's Overview/metrics/traces/session routes filtered reads through the core's mutable activeNamespace, which every turn/session rewrites from caller context hints (openclaw: profileId = ctx.agentId). When the gateway session or a sub-agent turn flipped the profile, all historical rows failed the visibility clauses and dashboard counts collapsed to zero. The reported root cause (missing chunks table) is a misdiagnosis: the only 2.0 reference to chunks is the legacy 1.0-import reader in server/routes/migrate.ts; the live pipeline queries traces/episodes/ sessions with vectors stored in BLOB columns, so no schema change is needed. Fix: complete the existing includeAllNamespaces viewer convention (already used by diag.ts/session.ts/memory.ts) — add the option to core.metrics() and core.listEpisodes(), and pass it from overview.ts, metrics.ts, trace.ts and session.ts. Scoped reads and turn-time retrieval isolation are unchanged; explicit owner filters still narrow. Co-Authored-By: Claude Fable 5 * fix: align listApiLogs namespace scoping with viewer metrics feeds Follow-up to #2131 (OCR round 1/2): - listApiLogs: accept includeAllNamespaces in the contract and core facade (contract symmetry with listTraces/listSkills/listPolicies); metrics/tools now passes it explicitly so both feeds of the bump() aggregation are scoped identically - metrics(): document that the traces fetch feeding sessions/ writesToday/embeddings/dailyWrites is intentionally cross-namespace; only totalTurns respects includeAllNamespaces - listEpisodes: apply limit/offset after the visibility filter on the namespace-scoped path so pages are not silently under-filled by other namespaces' rows Co-Authored-By: Claude Fable 5 --------- Co-authored-by: memos-autodev[bot] Co-authored-by: Claude Fable 5 --- .../agent-contract/memory-core.ts | 18 ++- .../core/pipeline/memory-core.ts | 50 +++++-- .../server/routes/metrics.ts | 16 ++- .../server/routes/overview.ts | 16 ++- .../server/routes/session.ts | 7 +- .../memos-local-plugin/server/routes/trace.ts | 6 + .../tests/unit/pipeline/memory-core.test.ts | 125 ++++++++++++++++++ .../tests/unit/server/http.test.ts | 18 ++- 8 files changed, 237 insertions(+), 19 deletions(-) diff --git a/apps/memos-local-plugin/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index ad1dd3f13..70fbb12d8 100644 --- a/apps/memos-local-plugin/agent-contract/memory-core.ts +++ b/apps/memos-local-plugin/agent-contract/memory-core.ts @@ -369,7 +369,7 @@ export interface MemoryCore { archiveWorldModel(id: string): Promise; /** Reverse of {@link archiveWorldModel}. */ unarchiveWorldModel(id: string): Promise; - listEpisodes(input: { sessionId?: SessionId; limit?: number; offset?: number }): Promise; + listEpisodes(input: { sessionId?: SessionId; limit?: number; offset?: number; includeAllNamespaces?: boolean }): Promise; /** * Like `listEpisodes` but returns rich per-row metadata the viewer * needs to render its task list without a second round trip @@ -435,6 +435,20 @@ export interface MemoryCore { toolNames?: readonly string[]; limit?: number; offset?: number; + /** + * When true, ignore the (turn-scoped) active namespace and return + * rows from every namespace. Viewer callers set this so the Logs + * page and metrics aggregations stay stable across profile flips + * (#2131); matches the pattern already used by `listTraces` / + * `listSkills` / `listPolicies`. + * + * Note: the write path currently does not stamp per-namespace + * owner columns on `api_logs`, so this flag is a no-op today — + * every listing is effectively cross-namespace. Keeping the + * parameter formalises the contract now so a future per-namespace + * write can be added without breaking viewer callers. + */ + includeAllNamespaces?: boolean; }): Promise<{ logs: ApiLogDTO[]; total: number }>; // ── skills ── @@ -504,7 +518,7 @@ export interface MemoryCore { * Aggregate counts for the viewer's Analytics tab. `days` controls * the window the `dailyWrites` histogram covers. */ - metrics(input?: { days?: number }): Promise<{ + metrics(input?: { days?: number; includeAllNamespaces?: boolean }): Promise<{ total: number; writesToday: number; sessions: number; diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 934cbf2dc..fb156a38a 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -3211,15 +3211,30 @@ export function createMemoryCore( sessionId?: SessionId; limit?: number; offset?: number; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); - const rows = handle.repos.episodes.list({ - sessionId: input.sessionId, - limit: input.limit ?? 50, - offset: input.offset ?? 0, - }); - return rows + const limit = input.limit ?? 50; + const offset = input.offset ?? 0; + if (input.includeAllNamespaces) { + // Every row passes the visibility filter, so repo-level paging + // is both correct and cheap. + return handle.repos.episodes + .list({ sessionId: input.sessionId, limit, offset }) + .map((r: EpisodeRow) => r.id as EpisodeId); + } + // Namespace-scoped path: paging at the repo level would apply + // `limit` before the visibility filter runs, silently under-filling + // pages (callers can't tell a short page from end-of-data). Fetch + // the widest window the repo allows, filter, then page in memory — + // same idiom as `listEpisodeRows` / `countEpisodes`. The repo + // clamps the fetch window to 500 rows (`clampLimit`), so scoped + // paging is exact within the newest 500 episodes; beyond that the + // same shared limitation applies to the sibling list/count methods. + return handle.repos.episodes + .list({ sessionId: input.sessionId, limit: 100_000 }) .filter((r: EpisodeRow) => visibleToCurrent(r)) + .slice(offset, offset + limit) .map((r: EpisodeRow) => r.id as EpisodeId); } @@ -3422,8 +3437,16 @@ export function createMemoryCore( toolNames?: readonly string[]; limit?: number; offset?: number; + includeAllNamespaces?: boolean; }): Promise<{ logs: ApiLogDTO[]; total: number }> { ensureLive(); + // `includeAllNamespaces` is accepted for contract symmetry with the + // other viewer list* methods (#2131). The `api_logs` write path does + // not currently stamp per-namespace owner columns, so every row is + // effectively cross-namespace regardless of this flag. Callers that + // fan into viewer aggregations still pass `true` so their intent is + // explicit if a per-namespace write path is added later. + void input?.includeAllNamespaces; const limit = Math.max(1, Math.min(500, input?.limit ?? 50)); const offset = Math.max(0, input?.offset ?? 0); const rows = handle.repos.apiLogs.list({ @@ -3786,7 +3809,7 @@ export function createMemoryCore( }); } - async function metrics(input?: { days?: number }): Promise<{ + async function metrics(input?: { days?: number; includeAllNamespaces?: boolean }): Promise<{ total: number; writesToday: number; sessions: number; @@ -3824,6 +3847,14 @@ export function createMemoryCore( const oneDayMs = 86_400_000; const sinceMs = now - days * oneDayMs; + // NOTE: `traces` is fetched unfiltered (all namespaces) below so + // that `sessions` / `writesToday` / `embeddings` / `dailyWrites` + // populate the viewer chart even after a turn from a different + // profile flips the active namespace (#2131). Only `total` + // (totalTurns) respects `includeAllNamespaces`. If a per-namespace + // scoping is ever needed for these derived fields (e.g. per-agent + // views), thread `visibilityWhere(activeNamespace)` through the + // repo query the same way `countTurns` does. const traces = handle.repos.traces.list({ limit: 10_000 }); const sessions = new Set(); let writesToday = 0; @@ -3931,9 +3962,12 @@ export function createMemoryCore( // shows: 1 user turn = 1 memory (regardless of how many tool calls // / sub-steps were captured for that turn). // Apply namespace visibility so the count matches the filtered list. + // Viewer callers pass `includeAllNamespaces` — `activeNamespace` is + // rewritten by every turn/session, so binding this count to it made + // the dashboard total collapse whenever another profile's turn ran. const totalTurns = handle.repos.traces.countTurns( {}, - visibilityWhere(activeNamespace), + input?.includeAllNamespaces ? undefined : visibilityWhere(activeNamespace), ); return { diff --git a/apps/memos-local-plugin/server/routes/metrics.ts b/apps/memos-local-plugin/server/routes/metrics.ts index fdf6b583f..1c7ecb93d 100644 --- a/apps/memos-local-plugin/server/routes/metrics.ts +++ b/apps/memos-local-plugin/server/routes/metrics.ts @@ -41,8 +41,11 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/metrics", async (ctx) => { const raw = ctx.url.searchParams.get("days"); const days = raw ? Number(raw) : undefined; + // Viewer analytics cover the whole local database — see overview.ts + // for why viewer reads must not follow the turn-scoped namespace. return await deps.core.metrics({ days: Number.isFinite(days) ? days : undefined, + includeAllNamespaces: true, }); }); @@ -101,7 +104,16 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void { // tools, and their timings reflect background work rather than // response latency. const PUBLIC_API_LOG_TOOLS = new Set(["memos_search", "memory_search", "memory_add"]); - const { logs } = await deps.core.listApiLogs({ limit: 5_000, offset: 0 }); + // Same convention as the `listTraces` call below (#2131): the tool + // panel folds api_logs entries and trace tool-calls into a single + // aggregation, so both feeds must be scoped identically. Passing + // `includeAllNamespaces: true` here keeps them aligned even if a + // future per-namespace write path lands on api_logs. + const { logs } = await deps.core.listApiLogs({ + limit: 5_000, + offset: 0, + includeAllNamespaces: true, + }); for (const lg of logs) { if (lg.calledAt < sinceMs) continue; if (!PUBLIC_API_LOG_TOOLS.has(lg.toolName)) continue; @@ -112,7 +124,7 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void { // web / whatever the agent ran. Fold them in with the api_logs // rows so the panel answers "is anything slow?" regardless of // whether the slowness was internal or user-visible. - const traces = await deps.core.listTraces({ limit: 2_000, offset: 0 }); + const traces = await deps.core.listTraces({ limit: 2_000, offset: 0, includeAllNamespaces: true }); for (const tr of traces) { if (tr.ts < sinceMs) continue; for (const tc of tr.toolCalls ?? []) { diff --git a/apps/memos-local-plugin/server/routes/overview.ts b/apps/memos-local-plugin/server/routes/overview.ts index d19504e2b..df3085da1 100644 --- a/apps/memos-local-plugin/server/routes/overview.ts +++ b/apps/memos-local-plugin/server/routes/overview.ts @@ -27,19 +27,25 @@ export function registerOverviewRoutes(routes: Routes, deps: ServerDeps): void { // headless callers. Routing the ping through the viewer's mount // hook keeps the semantics honest (a browser actually opened // the page) and is naturally deduped by browser tab lifetime. + // The viewer is a local single-user admin surface: its aggregate + // counts must reflect the whole database, not the namespace of + // whichever agent profile processed the most recent turn. The core + // rewrites its active namespace on every turn/session, so scoped + // reads here made the dashboard "drift to zero" as soon as a + // message arrived (#2131). Same convention as diag.ts / session.ts. const [health, episodeIds, skills, policies, worldModels, metrics] = await Promise.all([ deps.core.health(), - deps.core.listEpisodes({ limit: 5_000 }), - deps.core.listSkills({ limit: 500 }), + deps.core.listEpisodes({ limit: 5_000, includeAllNamespaces: true }), + deps.core.listSkills({ limit: 500, includeAllNamespaces: true }), // Core only exposes `listPolicies({ status? })`; the viewer wants // the grand total + per-status so we request the biggest page and // break it down here. 500 is plenty — fresh installs have dozens. - deps.core.listPolicies({ limit: 500 }), - deps.core.listWorldModels({ limit: 500 }), + deps.core.listPolicies({ limit: 500, includeAllNamespaces: true }), + deps.core.listWorldModels({ limit: 500, includeAllNamespaces: true }), // `metrics.total` is the grand total of traces — cheaper than a // dedicated count RPC and already cached by the core. - deps.core.metrics({ days: 1 }), + deps.core.metrics({ days: 1, includeAllNamespaces: true }), ]); const skillStats = { diff --git a/apps/memos-local-plugin/server/routes/session.ts b/apps/memos-local-plugin/server/routes/session.ts index 135b55704..2bf3fc639 100644 --- a/apps/memos-local-plugin/server/routes/session.ts +++ b/apps/memos-local-plugin/server/routes/session.ts @@ -91,7 +91,12 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { ownerProfileId, includeAllNamespaces: true, }); - const episodeIds = await deps.core.listEpisodes({ sessionId, limit, offset }); + const episodeIds = await deps.core.listEpisodes({ + sessionId, + limit, + offset, + includeAllNamespaces: true, + }); return { episodeIds, limit, diff --git a/apps/memos-local-plugin/server/routes/trace.ts b/apps/memos-local-plugin/server/routes/trace.ts index ec1dca4c8..7a8e742dd 100644 --- a/apps/memos-local-plugin/server/routes/trace.ts +++ b/apps/memos-local-plugin/server/routes/trace.ts @@ -44,6 +44,10 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { const groupByTurn = params.get("groupByTurn") === "true"; const includeTotal = params.get("includeTotal") !== "false"; const listLimit = includeTotal ? limit : limit + 1; + // Viewer list: show every namespace's rows by default (explicit + // ownerAgentKind / ownerProfileId query filters still narrow) — + // see overview.ts for why viewer reads must not follow the + // turn-scoped active namespace. const rawTraces = await deps.core.listTraces({ limit: listLimit, offset, @@ -52,6 +56,7 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { ownerProfileId, q, groupByTurn, + includeAllNamespaces: true, }); const { traces, hasMore } = trimTracePage(rawTraces, limit, groupByTurn); const total = includeTotal @@ -61,6 +66,7 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { ownerProfileId, q, groupByTurn, + includeAllNamespaces: true, }) : undefined; // When grouping, `traces.length === limit` is no longer a reliable diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index 62c18fa16..ad86ff751 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -451,6 +451,131 @@ describe("MemoryCore façade", () => { expect(await core.listTraces({ limit: 10, groupByTurn: true })).toHaveLength(1); }); + // Regression for #2131: viewer dashboard counts must not "drift to + // zero" when a turn/session from a different sub-agent profile flips + // the core's active namespace. The viewer is a local single-user + // admin surface, so its aggregate reads pass `includeAllNamespaces` + // (same convention as diag.ts / session.ts routes) and must stay + // stable regardless of which namespace processed the last turn. + it("keeps metrics + listEpisodes stable across a namespace flip (includeAllNamespaces)", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + const mainNs = { agentKind: "openclaw", profileId: "main" }; + const subagentNs = { agentKind: "openclaw", profileId: "subagent-x" }; + + // 1. A turn under the boot namespace writes one memory. + const start = await core.onTurnStart({ + agent: "openclaw", + namespace: mainNs, + sessionId: "s-main", + userText: "remember the deploy checklist", + ts: 1_700_000_000_001, + }); + await core.onTurnEnd({ + agent: "openclaw", + namespace: mainNs, + sessionId: "s-main", + episodeId: start.query.episodeId!, + agentText: "stored the deploy checklist", + toolCalls: [], + ts: 1_700_000_000_002, + }); + + const before = await core.metrics({ days: 1, includeAllNamespaces: true }); + expect(before.total).toBe(1); + const episodesBefore = await core.listEpisodes({ + limit: 10, + includeAllNamespaces: true, + }); + expect(episodesBefore).toHaveLength(1); + + // 2. A session under a DIFFERENT profile flips activeNamespace + // (this is what the gateway/sub-agents do in production). + await core.openSession({ + agent: "openclaw", + sessionId: "s-sub", + namespace: subagentNs, + }); + + // Namespace-scoped reads hide the other profile's row (intended + // multi-profile isolation)… + const scoped = await core.metrics({ days: 1 }); + expect(scoped.total).toBe(0); + await expect(core.listEpisodes({ limit: 10 })).resolves.toHaveLength(0); + + // …but the viewer's all-namespace reads must NOT drift. + const after = await core.metrics({ days: 1, includeAllNamespaces: true }); + expect(after.total).toBe(1); + const episodesAfter = await core.listEpisodes({ + limit: 10, + includeAllNamespaces: true, + }); + expect(episodesAfter).toHaveLength(1); + }); + + // Namespace-scoped listEpisodes must apply `limit` AFTER the + // visibility filter. Paging at the repo level under-fills pages when + // rows from other namespaces occupy the fetched window, which reads + // as a false end-of-data to paginating callers. + it("fills scoped listEpisodes pages past other namespaces' rows", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + const mainNs = { agentKind: "openclaw", profileId: "main" }; + const subNs = { agentKind: "openclaw", profileId: "subagent-x" }; + + const runTurn = async ( + ns: typeof mainNs, + sessionId: string, + ts: number, + ): Promise => { + const start = await core!.onTurnStart({ + agent: "openclaw", + namespace: ns, + sessionId, + userText: `note for ${sessionId}`, + ts, + }); + await core!.onTurnEnd({ + agent: "openclaw", + namespace: ns, + sessionId, + episodeId: start.query.episodeId!, + agentText: `stored for ${sessionId}`, + toolCalls: [], + ts: ts + 1, + }); + }; + + // Three episodes, newest-first order: main-2, sub-1, main-1 — the + // sub-profile row sits inside the first page window of size 2. + await runTurn(mainNs, "s-main-1", 1_700_000_000_010); + await runTurn(subNs, "s-sub-1", 1_700_000_000_020); + await runTurn(mainNs, "s-main-2", 1_700_000_000_030); + + // Active namespace is `main` after the last turn. A scoped page of + // 2 must contain BOTH main episodes, skipping the interleaved + // sub-profile row instead of consuming a page slot on it. + const page = await core.listEpisodes({ limit: 2 }); + expect(page).toHaveLength(2); + + // And the all-namespace read still sees everything. + await expect( + core.listEpisodes({ limit: 10, includeAllNamespaces: true }), + ).resolves.toHaveLength(3); + }); + it("records visible subagent task and result in the parent episode", async () => { pipeline = createPipeline(buildDeps(db!)); core = createMemoryCore( diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index 9e4c2a1f8..c3ba213e5 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -470,7 +470,8 @@ describe("HTTP server — REST routes", () => { expect(Array.isArray(body.traces)).toBe(true); expect(body.traces[0]?.id).toBe("tr-1"); expect(body.traces[0]?.summary).toBe("greeted"); - // The route must forward the query string into the core call. + // The route must forward the query string into the core call, and + // pin the viewer to all-namespace reads (#2131 drift regression). expect(core.listTraces).toHaveBeenCalledWith({ limit: 25, offset: 0, @@ -479,6 +480,7 @@ describe("HTTP server — REST routes", () => { groupByTurn: false, ownerAgentKind: undefined, ownerProfileId: undefined, + includeAllNamespaces: true, }); }); @@ -520,6 +522,20 @@ describe("HTTP server — REST routes", () => { expect(Array.isArray(body.dailyWrites)).toBe(true); }); + it("GET /api/v1/metrics/tools scopes both data feeds to all namespaces", async () => { + const r = await fetch(`${handle.url}/api/v1/metrics/tools?minutes=60`); + expect(r.status).toBe(200); + // The tool panel folds api_logs entries and trace tool-calls into + // one aggregation; both feeds must be pinned to all-namespace reads + // or the chart under-reports after a namespace flip (#2131). + expect(core.listApiLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeAllNamespaces: true }), + ); + expect(core.listTraces).toHaveBeenCalledWith( + expect.objectContaining({ includeAllNamespaces: true }), + ); + }); + it("GET /api/v1/config returns resolved config", async () => { const r = await fetch(`${handle.url}/api/v1/config`); expect(r.status).toBe(200); From 0e8b187cab7641b6a49df90dd081c1da91c6dc8a Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:31:25 +0200 Subject: [PATCH 19/33] fix(plugin): paginate dirty-closed reward recovery (#2120) * fix(plugin): page dirty-closed reward recovery * fix(plugin): share dirty scan cursor type --------- Co-authored-by: HarveyXiang --- .../core/pipeline/memory-core.ts | 45 ++++++- .../core/storage/repos/episodes.ts | 29 +++++ .../tests/unit/pipeline/memory-core.test.ts | 123 ++++++++++++++++++ 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index fb156a38a..39c6f3049 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -86,6 +86,7 @@ import { FLOAT32_BYTES, } from "../storage/repos/index.js"; import type { EmbeddingCountsBucket } from "../storage/repos/index.js"; +import type { ClosedEpisodeCursor } from "../storage/repos/episodes.js"; import { createEmbedder } from "../embedding/embedder.js"; import { createLlmClient } from "../llm/client.js"; import { @@ -644,6 +645,8 @@ export function createMemoryCore( const MAX_DIRTY_REWARD_ATTEMPTS = 3; const DIRTY_REWARD_BACKOFF_BASE_MS = 60 * 60 * 1000; // 1h const DIRTY_REWARD_BACKOFF_MAX_MS = 24 * 60 * 60 * 1000; // 24h + const DIRTY_CLOSED_SCAN_PAGE_SIZE = 500; + const DIRTY_CLOSED_SCAN_CURSOR_KEY = "pipeline.dirty_closed_scan_cursor.v1"; // ─── Startup recovery background promise (issue #1776 + #1808) ── // `init()` used to `await` the entire reflect → reward → L2 chain for @@ -727,8 +730,7 @@ export function createMemoryCore( // must be a no-op. if (handle.algorithm.lightweightMemory.enabled) return; try { - const allDirty = handle.repos.episodes - .list({ status: "closed", limit: 500 }) + const allDirty = collectDirtyClosedEpisodes() .filter((ep) => !isLightweightEpisode(ep) && episodeRewardIsDirty(ep)); // Apply the same backoff filter as init() so the 10-min periodic // scan does not hammer episodes whose LLM call keeps failing. @@ -1106,8 +1108,7 @@ export function createMemoryCore( dirtyClosedForBackground = []; } else { const nowForDirty = Date.now(); - const allDirty = handle.repos.episodes - .list({ status: "closed", limit: 500 }) + const allDirty = collectDirtyClosedEpisodes() .filter((ep) => !isLightweightEpisode(ep) && episodeRewardIsDirty(ep)); const dirtyClosed: typeof allDirty = []; for (const ep of allDirty) { @@ -1551,6 +1552,42 @@ export function createMemoryCore( } } + function collectDirtyClosedEpisodes(): Array }> { + const storedCursor = handle.repos.kv.get(DIRTY_CLOSED_SCAN_CURSOR_KEY, null); + const cursor = isDirtyClosedScanCursor(storedCursor) ? storedCursor : null; + if (storedCursor !== null && !cursor) { + handle.repos.kv.del(DIRTY_CLOSED_SCAN_CURSOR_KEY); + } + const page = handle.repos.episodes.listClosedPage({ + limit: DIRTY_CLOSED_SCAN_PAGE_SIZE, + before: cursor, + }); + if (page.length === 0) { + handle.repos.kv.del(DIRTY_CLOSED_SCAN_CURSOR_KEY); + return []; + } + + const last = page[page.length - 1]!; + if (page.length < DIRTY_CLOSED_SCAN_PAGE_SIZE) { + handle.repos.kv.del(DIRTY_CLOSED_SCAN_CURSOR_KEY); + } else { + handle.repos.kv.set(DIRTY_CLOSED_SCAN_CURSOR_KEY, { + startedAt: last.startedAt, + id: last.id, + }); + } + return page; + } + + function isDirtyClosedScanCursor(value: unknown): value is ClosedEpisodeCursor { + return Boolean( + value && + typeof value === "object" && + typeof (value as { startedAt?: unknown }).startedAt === "number" && + typeof (value as { id?: unknown }).id === "string", + ); + } + function episodeRewardIsDirty(ep: EpisodeRow & { meta?: Record }): boolean { const meta = ep.meta ?? {}; if (meta.lightweightMemory === true) return false; diff --git a/apps/memos-local-plugin/core/storage/repos/episodes.ts b/apps/memos-local-plugin/core/storage/repos/episodes.ts index 1322b2afb..78b49d5fc 100644 --- a/apps/memos-local-plugin/core/storage/repos/episodes.ts +++ b/apps/memos-local-plugin/core/storage/repos/episodes.ts @@ -3,6 +3,7 @@ import type { EpisodeListFilter, StorageDb } from "../types.js"; import { buildInsert, buildUpdate } from "../tx.js"; import { buildPageClauses, + clampLimit, fromJsonText, joinWhere, normalizeShareForStorage, @@ -31,6 +32,11 @@ export interface EpisodeMetaRow { meta?: Record; } +export interface ClosedEpisodeCursor { + startedAt: number; + id: string; +} + export function makeEpisodesRepo(db: StorageDb) { const insert = db.prepare(buildInsert({ table: "episodes", columns: COLUMNS })); const replace = db.prepare( @@ -176,6 +182,29 @@ export function makeEpisodesRepo(db: StorageDb) { return db.prepare(sql).all(params).map(mapRow); }, + /** + * Return one stable, newest-first page of closed episodes. A cursor keeps + * scans progressing when new rows are inserted ahead of an earlier page. + */ + listClosedPage(opts: { + limit?: number; + before?: ClosedEpisodeCursor | null; + } = {}): Array { + const limit = clampLimit(opts.limit ?? 500); + const params: Record = { status: "closed" }; + const fragments = ["status = @status"]; + if (opts.before) { + fragments.push( + "(started_at < @before_started_at OR (started_at = @before_started_at AND id < @before_id))", + ); + params.before_started_at = opts.before.startedAt; + params.before_id = opts.before.id; + } + const where = joinWhere(fragments); + const sql = `SELECT ${COLUMNS.join(", ")} FROM episodes ${where} ORDER BY started_at DESC, id DESC LIMIT ${limit}`; + return db.prepare(sql).all(params).map(mapRow); + }, + count(filter: Omit = {}): number { const tr = timeRangeWhere(filter, "started_at"); const fragments: string[] = []; diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index ad86ff751..aad881593 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -1644,6 +1644,129 @@ algorithm: expect(meta.reward?.traceIds).toEqual(["tr_missing_reward"]); }); + it("continues a bounded dirty-closed scan past 500 rows across restart", async () => { + home = await makeTmpHome({ + agent: "openclaw", + configYaml: FULL_MEMORY_CONFIG_YAML, + }); + + const seeder = await bootstrapMemoryCore({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "dirty-page-cursor-seed", + }); + await seeder.init(); + await seeder.shutdown(); + + const Sqlite = (await import("better-sqlite3")).default; + const writeDb = new Sqlite(home.home.dbFile); + const ts = Date.now() - 10_000; + writeDb + .prepare( + `INSERT INTO sessions (id, agent, started_at, last_seen_at, meta_json) VALUES (?, ?, ?, ?, ?)`, + ) + .run("se_dirty_page_cursor", "openclaw", ts, ts, "{}"); + const insertEpisode = writeDb.prepare( + `INSERT INTO episodes (id, session_id, started_at, ended_at, trace_ids_json, r_task, status, meta_json) VALUES (?, ?, ?, ?, ?, ?, 'closed', ?)`, + ); + for (let i = 0; i < 500; i++) { + insertEpisode.run( + `ep_clean_${i}`, + "se_dirty_page_cursor", + ts + i, + ts + i + 1, + "[]", + 0, + JSON.stringify({ closeReason: "finalized" }), + ); + } + insertEpisode.run( + "ep_dirty_past_first_page", + "se_dirty_page_cursor", + ts - 1, + ts, + JSON.stringify(["tr_dirty_past_first_page"]), + null, + JSON.stringify({ closeReason: "finalized" }), + ); + writeDb + .prepare( + `INSERT INTO traces ( + id, episode_id, session_id, ts, user_text, agent_text, summary, + tool_calls_json, reflection, agent_thinking, value, alpha, r_human, + priority, tags_json, error_signatures_json, vec_summary, vec_action, + share_scope, share_target, shared_at, turn_id, schema_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?)`, + ) + .run( + "tr_dirty_past_first_page", + "ep_dirty_past_first_page", + "se_dirty_page_cursor", + ts, + "Please recover the closed episode beyond the initial page.", + "The recovery should eventually score this episode.", + "Recovery pagination regression", + "[]", + null, + null, + 0, + 0, + null, + 0.5, + "[]", + "[]", + ts, + 1, + ); + writeDb.close(); + + const first = await bootstrapMemoryCore({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "dirty-page-cursor-first-scan", + }); + await first.init(); + await first.waitForStartupRecovery?.(); + await first.shutdown(); + + const betweenScansDb = new Sqlite(home.home.dbFile); + betweenScansDb.prepare( + `INSERT INTO episodes (id, session_id, started_at, ended_at, trace_ids_json, r_task, status, meta_json) VALUES (?, ?, ?, ?, ?, ?, 'closed', ?)`, + ).run( + "ep_inserted_between_scans", + "se_dirty_page_cursor", + ts + 10_000, + ts + 10_001, + "[]", + 0, + JSON.stringify({ closeReason: "finalized" }), + ); + betweenScansDb.close(); + + core = await bootstrapMemoryCore({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "dirty-page-cursor-second-scan", + }); + await core.init(); + await core.waitForStartupRecovery?.(); + + const readDb = new Sqlite(home.home.dbFile, { readonly: true }); + const episode = readDb + .prepare("SELECT r_task, meta_json FROM episodes WHERE id = ?") + .get("ep_dirty_past_first_page") as { r_task: number | null; meta_json: string } | undefined; + readDb.close(); + + expect(episode).toBeDefined(); + expect(episode!.r_task).toBe(0); + expect(JSON.parse(episode!.meta_json)).toMatchObject({ + recoveryReason: RECOVERY_REASONS.DIRTY_REWARD_RESCORE, + }); + }); + it("init() returns immediately even when a stale orphan's recovery chain stalls (issue #1808)", async () => { // Issue #1808: on databases with 30k+ traces, the dreaming chain // synchronous-await inside `init()` blocked the OpenClaw Gateway's From db45f40958d51f13ea7ddf5a7b03023189a41e8f Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:37:15 +0200 Subject: [PATCH 20/33] fix(adapter): fire session.close in daemon thread to unblock event loop (#1953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(adapter): fire session.close in daemon thread to unblock event loop on_session_end() called bridge.request("session.close") inline with a 30 s blocking urlopen(). gateway/run.py calls this synchronously from _handle_reset_command (an async fn), so the blocking I/O ran on the asyncio event loop thread, preventing Discord heartbeats from firing and causing forced reconnection after 10–30 s. The session.close response is unused and errors are already suppressed, so the call is semantically fire-and-forget. Moving it to a daemon thread is the correct fix: the event loop is never blocked, the request still goes out, and a 5 s timeout keeps it bounded if the bridge is dead. Reproducer: Violet 2026-06-12 09:54 (agent.log lines 3629–3791). Spec: ~/specs/memos-bridge-blocking-shutdown-spec.md * Fix #1897: bug(memos-local-plugin): Hermes background model status checks can trigger paid (#1899) * Fix #1897: fix(memos-local-plugin): add LLM circuit breaker for terminal provider errors Issue #1897 reported ~12,900 paid LLM requests in 24 h on Hermes against a DeepSeek key with insufficient balance. The local `system_model_status` row count (12,900) closely tracked the provider-side `request_count` (11,344) for the same billing window. The naming is misleading: `system_model_status` is not a health probe; it is the audit row written once per LLM call (ok / fallback / error) inside `core/llm/client.ts`. With no circuit breaker, every pipeline subscriber (capture / session-relation / reward / L2 / L3 / skill / retrieval LLM filter / world-model) kept firing on every turn / closed episode / induction, generating one paid request each. Add a per-`LlmClient` circuit breaker: - Trips on terminal errors: HTTP 401/402/403 or messages containing `insufficient balance` / `invalid api key` / `unauthorized` / `account suspended` / `billing`. - Open: short-circuits subsequent calls inside the facade without contacting the provider. Throws `MemosError(LLM_UNAVAILABLE)` with `details.circuitOpen=true` so existing catch blocks still work. - Half-open after cool-down (default 5 min, configurable, min 30 s): next call probes the provider; success closes the breaker, terminal failure re-opens it for another cool-down. - Host fallback rescues a call without tripping the breaker — fallback exists precisely to keep going when the primary is down. - Coalesces `system_model_status="circuit_open"` audit rows to at most one per ~25 s while the breaker stays open, so we don't replace paid spam with audit-row spam. - Exposes `circuitOpen` / `circuitOpenUntil` / `circuitOpenedReason` via `LlmClientStats` for the Overview viewer card. - Enabled by default; legacy behaviour available via `circuitBreaker.enabled = false`. Tests: 9 new vitest cases under `tests/unit/llm/client.test.ts` covering trip on 402, trip on "insufficient balance" message, no trip on generic transient, coalescing, half-open close on success, host-fallback rescues without trip, disabled mode, stats fields, and re-open on terminal probe failure. All 59 LLM and 28 pipeline tests pass; `tsc --noEmit` clean. Out of scope (tracked separately): 429 `Retry-After` handling (issue #1620), per-tool rate limits, daily budget caps. * Fix LLM breaker with host fallback --------- Co-authored-by: autodev-bot Co-authored-by: Jiang <33757498+hijzy@users.noreply.github.com> Co-authored-by: GU TIANCHUN <96930846+TianchunGu@users.noreply.github.com> Co-authored-by: Dubberman <48425266+whipser030@users.noreply.github.com> --------- Co-authored-by: Memtensor-AI Co-authored-by: autodev-bot Co-authored-by: Jiang <33757498+hijzy@users.noreply.github.com> Co-authored-by: GU TIANCHUN <96930846+TianchunGu@users.noreply.github.com> Co-authored-by: Dubberman <48425266+whipser030@users.noreply.github.com> --- .../adapters/hermes/memos_provider/__init__.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py index 47392b8a7..0ff76cf89 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py @@ -1598,8 +1598,20 @@ def on_session_end(self, messages: list[dict[str, Any]]) -> None: # type: ignor # the core will pause or finalize the open episode according to # topic-boundary rules so interrupted Hermes sessions can resume # into the same task later. - with contextlib.suppress(Exception): - self._bridge.request("session.close", {"sessionId": self._session_id}) + # + # Fire session.close in a daemon thread — the response is unused, so + # this is semantically fire-and-forget. Calling urlopen() inline blocks + # the asyncio event loop (gateway/run.py calls us synchronously from + # _handle_reset_command) and causes Discord heartbeat timeouts when the + # bridge is unresponsive. 5 s timeout keeps it bounded. + _bridge = self._bridge + _sid = self._session_id + + def _close() -> None: + with contextlib.suppress(Exception): + _bridge.request("session.close", {"sessionId": _sid}, timeout=5.0) + + threading.Thread(target=_close, daemon=True).start() def __del__(self) -> None: # Safety net — if shutdown() was never called (e.g. caller forgot, From de415b7d0efc7171cef30cba0ab007aa7ebc1a79 Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:45:05 +0200 Subject: [PATCH 21/33] feat: configurable log timezone (#1956) feat(2c): add log timezone support Co-authored-by: Fayenix Co-authored-by: jiachengzhen --- .../agent-contract/log-record.ts | 2 + .../core/config/defaults.ts | 1 + apps/memos-local-plugin/core/config/index.ts | 6 +++ apps/memos-local-plugin/core/config/schema.ts | 2 + .../core/logger/format/compact.ts | 2 +- .../core/logger/format/pretty.ts | 2 +- apps/memos-local-plugin/core/logger/index.ts | 6 +++ apps/memos-local-plugin/core/time.ts | 45 +++++++++++++++-- .../tests/unit/config/load.test.ts | 19 ++++++++ .../tests/unit/id-time.test.ts | 18 +++++++ .../tests/unit/logger/dispatch.test.ts | 48 +++++++++++++++++++ .../tests/unit/logger/format.test.ts | 30 ++++++++++++ 12 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 apps/memos-local-plugin/tests/unit/logger/format.test.ts diff --git a/apps/memos-local-plugin/agent-contract/log-record.ts b/apps/memos-local-plugin/agent-contract/log-record.ts index 81e87c072..59a35454b 100644 --- a/apps/memos-local-plugin/agent-contract/log-record.ts +++ b/apps/memos-local-plugin/agent-contract/log-record.ts @@ -56,6 +56,8 @@ export interface SerializedLogError { export interface LogRecord { /** Unix epoch milliseconds (UTC). */ ts: number; + /** IANA timezone used for display formatting. Canonical event time remains `ts`. */ + tz?: string; level: LogLevel; kind: LogKind; channel: string; diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 36c6ef4b8..c4756f1d2 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -311,6 +311,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { logging: { level: "info", detailedView: false, + timezone: "UTC", console: { enabled: true, pretty: true, channels: ["*"] }, file: { enabled: true, diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 1466c27ef..d7cb6638d 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -86,6 +86,12 @@ export function resolveConfig(raw: unknown, warnings?: string[]): ResolvedConfig }); } + try { + new Intl.DateTimeFormat("en-US", { timeZone: completed.logging.timezone }).format(0); + } catch { + throw new MemosError("config_invalid", `invalid logging.timezone: ${completed.logging.timezone}`); + } + return Object.freeze(completed) as ResolvedConfig; } diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 5a712a452..1e96b95ea 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -581,6 +581,8 @@ const LoggingSchema = Type.Object({ ], { default: "info" }), /** Viewer-only switch: expose detailed logs, lifecycle tags and chain view. */ detailedView: Bool(false), + /** IANA timezone for log timestamp display. */ + timezone: StringWithDefault("UTC"), console: Type.Object({ enabled: Bool(true), pretty: Bool(true), diff --git a/apps/memos-local-plugin/core/logger/format/compact.ts b/apps/memos-local-plugin/core/logger/format/compact.ts index 4058a45b5..1d1fd0521 100644 --- a/apps/memos-local-plugin/core/logger/format/compact.ts +++ b/apps/memos-local-plugin/core/logger/format/compact.ts @@ -8,7 +8,7 @@ import type { LogRecord } from "../types.js"; export function formatCompact(record: LogRecord): string { const parts: string[] = []; - parts.push(isoFromEpochMs(record.ts)); + parts.push(isoFromEpochMs(record.ts, record.tz, { offset: true })); parts.push(record.level); parts.push(record.kind); parts.push(`channel=${record.channel}`); diff --git a/apps/memos-local-plugin/core/logger/format/pretty.ts b/apps/memos-local-plugin/core/logger/format/pretty.ts index 66da5052b..d4f225e25 100644 --- a/apps/memos-local-plugin/core/logger/format/pretty.ts +++ b/apps/memos-local-plugin/core/logger/format/pretty.ts @@ -30,7 +30,7 @@ const KIND_TAG: Record = { }; export function formatPretty(record: LogRecord, opts: { color: boolean }): string { - const ts = isoFromEpochMs(record.ts).slice(11, 23); // HH:mm:ss.SSS + const ts = isoFromEpochMs(record.ts, record.tz).slice(11, 23); // HH:mm:ss.SSS const lvl = record.level.toUpperCase().padEnd(5); const kind = KIND_TAG[record.kind] ?? ""; const channel = record.channel ?? "?"; diff --git a/apps/memos-local-plugin/core/logger/index.ts b/apps/memos-local-plugin/core/logger/index.ts index 6c4f4087c..d9ac5afb0 100644 --- a/apps/memos-local-plugin/core/logger/index.ts +++ b/apps/memos-local-plugin/core/logger/index.ts @@ -62,6 +62,8 @@ interface LoggerCore { pid: number; host: string; seq: number; + /** IANA timezone used by display formatters. */ + tz: string; /** Whether file sinks are wired up (false in pre-init / null mode). */ filesActive: boolean; } @@ -90,6 +92,7 @@ function bootstrapConsoleOnly(): LoggerCore { pid: process.pid, host: hostname(), seq: 0, + tz: "UTC", filesActive: false, }; } @@ -199,6 +202,7 @@ export function initLogger( pid: process.pid, host: hostname(), seq: 0, + tz: logging.timezone, filesActive: filesEnabled, }; @@ -229,6 +233,7 @@ export function initTestLogger(): void { pid: process.pid, host: hostname(), seq: 0, + tz: "UTC", filesActive: false, }; } @@ -360,6 +365,7 @@ function emitToCore(record: LogRecord, skipLevelGate = false): void { host: core.host, src: "ts", seq: ++core.seq, + tz: core.tz, ...record, }; const safe = core.redactor.redact(enriched); diff --git a/apps/memos-local-plugin/core/time.ts b/apps/memos-local-plugin/core/time.ts index 84830c526..7437c48d9 100644 --- a/apps/memos-local-plugin/core/time.ts +++ b/apps/memos-local-plugin/core/time.ts @@ -44,7 +44,46 @@ export function formatDurationMs(ms: number): string { return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; } -/** Convenience: ISO 8601 string for a given epoch ms (UTC). */ -export function isoFromEpochMs(ms: EpochMs): string { - return new Date(ms).toISOString(); +export interface IsoFromEpochOptions { + /** Include numeric UTC offset for localized timestamps. UTC fast path stays unchanged. */ + offset?: boolean; +} + +/** Convenience: ISO 8601 string for a given epoch ms. Defaults to UTC. */ +export function isoFromEpochMs(ms: EpochMs, tz?: string, opts: IsoFromEpochOptions = {}): string { + if (!tz || tz === "UTC") return new Date(ms).toISOString(); + const d = new Date(ms); + const parts = dateTimeParts(d, tz); + const base = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond ?? "000"}`; + return opts.offset ? base + offsetForTimeZone(d, tz) : base; +} + +function dateTimeParts(d: Date, tz: string): Record { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + hour12: false, + }); + const out: Record = {}; + for (const part of fmt.formatToParts(d)) { + if (part.type !== "literal") out[part.type] = part.value; + } + return out; +} + +function offsetForTimeZone(d: Date, tz: string): string { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + timeZoneName: "longOffset", + hour: "2-digit", + }); + const zone = fmt.formatToParts(d).find((part) => part.type === "timeZoneName")?.value; + if (!zone || zone === "GMT" || zone === "UTC") return "+00:00"; + return zone.replace(/^GMT/, ""); } diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 8befbcf7c..5d85fd6e0 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; +import { MemosError } from "../../../agent-contract/errors.js"; import { DEFAULT_CONFIG, loadConfig, resolveConfig, resolveHome } from "../../../core/config/index.js"; import { makeTmpHome } from "../../helpers/tmp-home.js"; @@ -21,6 +22,24 @@ describe("config/loadConfig", () => { expect(result.config.embedding.provider).toBe(DEFAULT_CONFIG.embedding.provider); }); + it("defaults logging.timezone to UTC and accepts custom IANA zones", () => { + expect(resolveConfig({}).logging.timezone).toBe("UTC"); + const cfg = resolveConfig({ logging: { timezone: "America/Los_Angeles" } }); + expect(cfg.logging.timezone).toBe("America/Los_Angeles"); + }); + + it("rejects invalid logging.timezone with config_invalid", () => { + expect(() => resolveConfig({ logging: { timezone: "Not/AZone" } })).toThrow(MemosError); + try { + resolveConfig({ logging: { timezone: "Not/AZone" } }); + throw new Error("expected resolveConfig to reject invalid timezone"); + } catch (err) { + expect(MemosError.is(err)).toBe(true); + expect((err as MemosError).code).toBe("config_invalid"); + expect((err as MemosError).message).toContain("invalid logging.timezone"); + } + }); + it("defaults OpenRouter provider routing lists to empty arrays", () => { const cfg = resolveConfig({}); expect(cfg.llm.providerIgnore).toEqual([]); diff --git a/apps/memos-local-plugin/tests/unit/id-time.test.ts b/apps/memos-local-plugin/tests/unit/id-time.test.ts index 5f277640d..5b3f4dabc 100644 --- a/apps/memos-local-plugin/tests/unit/id-time.test.ts +++ b/apps/memos-local-plugin/tests/unit/id-time.test.ts @@ -56,4 +56,22 @@ describe("core/time", () => { const t = 1_700_000_000_000; expect(isoFromEpochMs(t)).toBe(new Date(t).toISOString()); }); + + it("isoFromEpochMs stays byte-identical to Date.toISOString by default and for UTC", () => { + const t = Date.UTC(2026, 5, 21, 21, 30, 45, 123); + expect(isoFromEpochMs(t)).toBe(new Date(t).toISOString()); + expect(isoFromEpochMs(t, "UTC")).toBe(new Date(t).toISOString()); + }); + + it("isoFromEpochMs formats local wall time for a configured timezone", () => { + const t = Date.UTC(2026, 5, 21, 21, 30, 45, 123); + expect(isoFromEpochMs(t, "America/Los_Angeles")).toBe("2026-06-21T14:30:45.123"); + }); + + it("isoFromEpochMs can include numeric UTC offset for compact logs", () => { + const summer = Date.UTC(2026, 5, 21, 21, 30, 45, 123); + const winter = Date.UTC(2026, 11, 21, 21, 30, 45, 123); + expect(isoFromEpochMs(summer, "America/Los_Angeles", { offset: true })).toBe("2026-06-21T14:30:45.123-07:00"); + expect(isoFromEpochMs(winter, "America/Los_Angeles", { offset: true })).toBe("2026-12-21T13:30:45.123-08:00"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts b/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts index ee3297761..93b8712f5 100644 --- a/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts +++ b/apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts @@ -155,4 +155,52 @@ logging: await rootLogger.flush(); expect(memoryBuffer().tail({ limit: 1 }).at(0)?.msg).toBe("py.heartbeat"); }); + + it("attaches configured timezone to locally-created records", async () => { + const yaml = ` +logging: + timezone: America/Los_Angeles +`; + const ctx = await makeTmpHome({ agent: "openclaw", configYaml: yaml }); + cleanup = ctx.cleanup; + initLogger(ctx.config, ctx.home); + + rootLogger.child({ channel: "core.session" }).info("timezone.local"); + await rootLogger.flush(); + + expect(memoryBuffer().tail({ limit: 1 }).at(0)?.tz).toBe("America/Los_Angeles"); + }); + + it("forward preserves explicit timezone and defaults missing timezone", async () => { + const yaml = ` +logging: + timezone: America/Los_Angeles +`; + const ctx = await makeTmpHome({ agent: "openclaw", configYaml: yaml }); + cleanup = ctx.cleanup; + initLogger(ctx.config, ctx.home); + + const log = rootLogger.child({ channel: "adapter.hermes" }); + log.forward({ + ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123), + level: "info", + kind: "app", + channel: "adapter.hermes", + msg: "py.explicit", + src: "py", + tz: "Europe/London", + }); + log.forward({ + ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123), + level: "info", + kind: "app", + channel: "adapter.hermes", + msg: "py.defaulted", + src: "py", + }); + + const tail = memoryBuffer().tail({ limit: 2 }); + expect(tail.find((r) => r.msg === "py.explicit")?.tz).toBe("Europe/London"); + expect(tail.find((r) => r.msg === "py.defaulted")?.tz).toBe("America/Los_Angeles"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/logger/format.test.ts b/apps/memos-local-plugin/tests/unit/logger/format.test.ts new file mode 100644 index 000000000..2d631d64e --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/logger/format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import type { LogRecord } from "../../../agent-contract/log-record.js"; +import { formatCompact } from "../../../core/logger/format/compact.js"; +import { formatPretty } from "../../../core/logger/format/pretty.js"; + +const base: LogRecord = { + ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123), + level: "info", + kind: "app", + channel: "core.session", + msg: "session.opened", +}; + +describe("logger/format", () => { + it("pretty formatter displays configured local time", () => { + const out = formatPretty({ ...base, tz: "America/Los_Angeles" }, { color: false }); + expect(out.startsWith("14:30:45.123 INFO [core.session] session.opened")).toBe(true); + }); + + it("compact formatter preserves UTC default output", () => { + const out = formatCompact(base); + expect(out.startsWith("2026-06-21T21:30:45.123Z info app ")).toBe(true); + }); + + it("compact formatter emits offset-bearing local timestamp", () => { + const out = formatCompact({ ...base, tz: "America/Los_Angeles" }); + expect(out.startsWith("2026-06-21T14:30:45.123-07:00 info app ")).toBe(true); + }); +}); From 0d37146074357ac376194d55b79852b6b431eb8e Mon Sep 17 00:00:00 2001 From: zhaxi Date: Thu, 23 Jul 2026 17:56:37 +0800 Subject: [PATCH 22/33] feat: chunk batch reflection scoring (#2146) Co-authored-by: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> --- .../core/capture/ALGORITHMS.md | 13 +- .../core/capture/batch-scorer.ts | 21 +- .../core/capture/capture.ts | 75 +++++-- .../tests/helpers/fake-llm.ts | 4 +- .../tests/unit/capture/capture-batch.test.ts | 183 +++++++++++++++--- 5 files changed, 238 insertions(+), 58 deletions(-) diff --git a/apps/memos-local-plugin/core/capture/ALGORITHMS.md b/apps/memos-local-plugin/core/capture/ALGORITHMS.md index 15a15ac64..49c98a105 100644 --- a/apps/memos-local-plugin/core/capture/ALGORITHMS.md +++ b/apps/memos-local-plugin/core/capture/ALGORITHMS.md @@ -78,7 +78,8 @@ priority once reward arrives. ## V7 §3.2 batched variant — `batch-scorer.ts` The per-step path (`reflection-synth.ts` + `alpha-scorer.ts`) issues 2N -LLM calls per N-step episode. `batch-scorer.ts` collapses them into ONE: +LLM calls per N-step episode. `batch-scorer.ts` collapses up to +`batchThreshold` steps into one call: ``` inputs = [{idx, state, action, outcome, reflection, synth_allowed}, …] @@ -91,8 +92,8 @@ Dispatch (in `capture.ts`): | `cfg.batchMode` | `cfg.batchThreshold` | behavior | |-------------------|----------------------|----------| | `per_step` | (ignored) | legacy: 2N calls | -| `per_episode` | (ignored) | always batch | -| `auto` (default) | `12` | batch when `N ≤ 12`; else per-step | +| `per_episode` | chunk size | batch when `N ≤ threshold`; else chunk-batch | +| `auto` (default) | `12` | batch when `N ≤ 12`; else chunk-batch | The dispatcher also refuses to batch when no LLM is wired — same fallback path as missing-LLM in per-step mode. @@ -107,15 +108,15 @@ Failure handling: - LLM throws / facade gives up after `malformedRetries=1` → capture catches in `runBatchScoring`, surfaces a `{stage: "batch"}` warning, - and the per-step path runs as a fallback. + and the per-step path runs as a fallback for that chunk. - Validator rejects on length mismatch, missing/non-numeric `alpha`, non-boolean `usable`, non-string `reflection_text`. Same fallback. Bookkeeping (`CaptureResult.llmCalls`): -- `batchedReflection`: 0 or 1 per episode (1 on a successful batch). +- `batchedReflection`: number of successful batch/chunk calls. - `reflectionSynth` / `alphaScoring`: only nonzero when the per-step path - ran (either selected directly, or as fallback after a batch failure). + ran (either selected directly, or as fallback after a chunk failure). Stable prompt fingerprint: diff --git a/apps/memos-local-plugin/core/capture/batch-scorer.ts b/apps/memos-local-plugin/core/capture/batch-scorer.ts index e7b8ab50f..da434c3b2 100644 --- a/apps/memos-local-plugin/core/capture/batch-scorer.ts +++ b/apps/memos-local-plugin/core/capture/batch-scorer.ts @@ -16,11 +16,12 @@ * `transferability` axes benefit directly. * * Trade-offs (encoded in capture.ts dispatch): - * - Prompt grows linearly with N steps. Capped via `batchThreshold`; - * long episodes degrade to the per-step path automatically. - * - One bad output value forces a single batched retry instead of N - * isolated retries — but the facade already does `malformedRetries` - * for us, and on hard failure capture.ts falls back to per-step. + * - Prompt grows linearly with N steps. Each call is capped at + * `batchThreshold`; long episodes run as several bounded chunks. + * - One bad chunk forces a single batched retry for that chunk instead + * of N isolated retries — but the facade already does + * `malformedRetries` for us, and on hard failure capture.ts falls + * back to per-step for that chunk only. * * Wire format ↔ prompt: * Send `{ host_context?, task_context?, steps: [{idx, state, action, outcome, reflection, synth_allowed}] }`. @@ -170,6 +171,7 @@ export async function batchScoreReflections( validate: (v) => validateBatchPayload(v, inputs.length), malformedRetries: 1, temperature: 0, + maxTokens: batchMaxTokens(inputs.length), }, ); @@ -321,6 +323,15 @@ function validateBatchPayload(v: unknown, expected: number): void { } } +function batchMaxTokens(stepCount: number): number { + // Batch output scales with step count; keep a per-step budget but cap below + // the 16k range that triggered avoidable reasoning spend on mimo replay. + const perStepOutputBudget = 512; + const baseBudget = 768; + const ceiling = 8_192; + return Math.min(ceiling, baseBudget + Math.max(1, stepCount) * perStepOutputBudget); +} + function lastToolOutcome(step: NormalizedStep, max: number): string { const last = step.toolCalls[step.toolCalls.length - 1]; if (!last) return "(assistant-only step)"; diff --git a/apps/memos-local-plugin/core/capture/capture.ts b/apps/memos-local-plugin/core/capture/capture.ts index d5b62aa38..bc9c7a812 100644 --- a/apps/memos-local-plugin/core/capture/capture.ts +++ b/apps/memos-local-plugin/core/capture/capture.ts @@ -463,14 +463,14 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { } // Batch reflection + α across every step of the now-closed - // episode. Falls back to per-step scoring when over the threshold - // or when batching fails / no LLM is wired. The reflect pass uses + // episode. Long episodes are chunk-batched at `batchThreshold`; + // failed chunks fall back to per-step scoring. The reflect pass uses // `reflectLlm` (skill-evolver model when configured) for higher // quality reflections; per-turn lite capture still uses `llm`. const reflectStart = now(); const rLlm = deps.reflectLlm ?? deps.llm; - const useBatch = shouldBatch(deps.cfg, normalized.length, rLlm !== null); - const contextEnabled = contextModeFor(deps.cfg, useBatch, normalized.length); + const scoringPlan = planScoring(deps.cfg, normalized.length, rLlm !== null); + const contextEnabled = contextModeFor(deps.cfg, scoringPlan, normalized.length); const taskSummary = contextEnabled.includeTask ? buildTaskReflectionSummary(input.episode, normalized, deps.cfg.taskContextMaxChars) : null; @@ -481,7 +481,10 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { episodeId: input.episode.id, sessionId: input.episode.sessionId, steps: normalized.length, - mode: useBatch ? "batch" : contextEnabled.includeDownstream ? "per_step_downstream" : "per_step", + mode: scoringPlan === "per_step" && contextEnabled.includeDownstream ? "per_step_downstream" : scoringPlan, + chunks: scoringPlan === "chunk_batch" + ? Math.ceil(normalized.length / Math.max(1, deps.cfg.batchThreshold)) + : undefined, reflectionContextMode: deps.cfg.reflectionContextMode, downstreamPreview: contextEnabled.includeDownstream, provider: rLlm?.provider ?? "none", @@ -489,10 +492,13 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { taskSummary: taskSummary ? taskSummary.slice(0, 240) : null, }); let scored: ScoredStep[] = []; - if (useBatch) { + if (scoringPlan === "batch") { scored = await runBatchScoring(normalized, rLlm!, deps, warnings, llmCalls, input.episode.id, taskSummary); } - if (!useBatch || scored.length === 0) { + if (scoringPlan === "chunk_batch") { + scored = await runChunkedBatchScoring(normalized, rLlm!, deps, warnings, llmCalls, input.episode.id, taskSummary); + } + if (scoringPlan === "per_step" || scored.length === 0) { scored = await runPerStepScoring( normalized, rLlm, @@ -1062,30 +1068,30 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { // ─── helpers ──────────────────────────────────────────────────────────────── /** - * Decide whether to use the batched reflection+α path. + * Decide which reflection+α path to use. * * `per_step` → never (legacy path). - * `per_episode` → always, when an LLM is available. - * `auto` → batch when step count fits inside `batchThreshold`. + * `per_episode` → batch up to threshold, then chunk-batch. + * `auto` → batch up to threshold, then chunk-batch. */ -function shouldBatch(cfg: CaptureConfig, stepCount: number, hasLlm: boolean): boolean { - if (!hasLlm) return false; - if (stepCount === 0) return false; - if (cfg.batchMode === "per_step") return false; - if (cfg.batchMode === "per_episode") return true; - // "auto" - return stepCount <= cfg.batchThreshold; +type ScoringPlan = "per_step" | "batch" | "chunk_batch"; + +function planScoring(cfg: CaptureConfig, stepCount: number, hasLlm: boolean): ScoringPlan { + if (!hasLlm) return "per_step"; + if (stepCount === 0) return "per_step"; + if (cfg.batchMode === "per_step") return "per_step"; + return stepCount <= Math.max(1, cfg.batchThreshold) ? "batch" : "chunk_batch"; } function contextModeFor( cfg: CaptureConfig, - useBatch: boolean, + scoringPlan: ScoringPlan, stepCount: number, ): { includeTask: boolean; includeDownstream: boolean } { const mode = cfg.reflectionContextMode; const includeTask = mode === "task" || mode === "task_downstream"; const wantsDownstream = mode === "downstream" || mode === "task_downstream"; - const longPerStep = !useBatch && stepCount > cfg.batchThreshold; + const longPerStep = scoringPlan === "per_step" && stepCount > cfg.batchThreshold; const includeDownstream = wantsDownstream && cfg.longEpisodeReflectMode === "per_step_downstream" && @@ -1145,6 +1151,37 @@ async function runBatchScoring( } } +async function runChunkedBatchScoring( + normalized: NormalizedStep[], + llm: LlmClient, + deps: CaptureDeps, + warnings: CaptureResult["warnings"], + llmCalls: { reflectionSynth: number; alphaScoring: number; batchedReflection: number }, + episodeId: string, + taskSummary: string | null, +): Promise { + const chunkSize = Math.max(1, deps.cfg.batchThreshold); + const chunks: NormalizedStep[][] = []; + for (let start = 0; start < normalized.length; start += chunkSize) { + chunks.push(normalized.slice(start, start + chunkSize)); + } + const concurrency = Math.max(1, deps.cfg.llmConcurrency); + const scoredChunks = await runConcurrently(chunks, concurrency, async (chunk): Promise => { + const scored = await runBatchScoring(chunk, llm, deps, warnings, llmCalls, episodeId, taskSummary); + if (scored.length > 0) return scored; + return runPerStepScoring( + chunk, + llm, + deps, + warnings, + llmCalls, + episodeId, + buildReflectionContexts(chunk, taskSummary, chunk.map(() => [])), + ); + }); + return scoredChunks.flat(); +} + async function runPerStepScoring( normalized: NormalizedStep[], llm: LlmClient | null, diff --git a/apps/memos-local-plugin/tests/helpers/fake-llm.ts b/apps/memos-local-plugin/tests/helpers/fake-llm.ts index 22d9fc1a3..ec2c00261 100644 --- a/apps/memos-local-plugin/tests/helpers/fake-llm.ts +++ b/apps/memos-local-plugin/tests/helpers/fake-llm.ts @@ -20,7 +20,7 @@ export interface FakeLlmScript { complete?: Record string | Promise)>; completeJson?: Record< string, - unknown | ((input: unknown) => unknown | Promise) + unknown | ((input: unknown, opts?: unknown) => unknown | Promise) >; /** Override the served-by identifier. */ servedBy?: LlmProviderName | "host_fallback"; @@ -64,7 +64,7 @@ export function fakeLlm(script: FakeLlmScript = {}): LlmClient { throw new Error(`fakeLlm: no completeJson mock for op="${op}"`); } const value = (typeof entry === "function" - ? await (entry as (x: unknown) => unknown)(input) + ? await (entry as (x: unknown, o?: unknown) => unknown)(input, opts) : entry) as T; if (o?.validate) o.validate(value); return { diff --git a/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts b/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts index d86290517..bc4b76f28 100644 --- a/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts @@ -7,14 +7,15 @@ * 2. existing reflections are preserved verbatim; * 3. synth-disabled steps stay at α=0 even when the LLM tries to write * one for them; - * 4. `auto` mode falls back to per-step when stepCount > batchThreshold; - * 5. a malformed batched response degrades into the per-step path - * instead of crashing capture. + * 4. `auto` mode chunk-batches when stepCount > batchThreshold; + * 5. a malformed chunk degrades only that chunk into the per-step path + * instead of dropping the whole episode to per-step. */ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createCaptureRunner, type CaptureRunner } from "../../../core/capture/capture.js"; +import { batchScoreReflections } from "../../../core/capture/batch-scorer.js"; import { createCaptureEventBus } from "../../../core/capture/events.js"; import { BATCH_REFLECTION_PROMPT, @@ -312,20 +313,31 @@ describe("capture/pipeline (batched ρ+α path)", () => { expect(t.alpha).toBe(0); // V7 disabledScore semantics }); - it("auto mode falls back to per-step when stepCount > batchThreshold", async () => { + it("auto mode chunk-batches when stepCount > batchThreshold", async () => { + const batchStates: string[][] = []; const llm = fakeLlm({ completeJson: { - // ONLY per-step alpha mock; if batched gets called, the test fails - // with "no completeJson mock for op=...batch...". - [alphaOp]: { alpha: 0.5, usable: true, reason: "ok" }, - }, - complete: { - "capture.reflection.synth": "I made this decision deliberately.", + [batchOp]: (input) => { + const messages = input as Array<{ role: string; content: string }>; + const payload = JSON.parse(messages[messages.length - 1]!.content) as { + steps: Array<{ idx: number; state: string }>; + }; + batchStates.push(payload.steps.map((s) => s.state)); + return { + scores: payload.steps.map((step) => ({ + idx: step.idx, + reflection_text: `reflection ${step.state}`, + alpha: step.idx === 0 ? 0.2 : 0.4, + usable: true, + reason: "ok", + })), + }; + }, }, }); const runner = buildRunner({ batchMode: "auto", batchThreshold: 2 }, llm); - // 3 steps → above threshold → per-step path. + // 3 steps → above threshold → two bounded batch chunks. const ep = episodeSnapshot({ id: "ep_1", sessionId: "se_1", @@ -341,10 +353,17 @@ describe("capture/pipeline (batched ρ+α path)", () => { const result = await runCapture(runner, ep); expect(result.traceIds).toHaveLength(3); - expect(result.llmCalls.batchedReflection).toBe(0); - // 3 synth + 3 alpha calls in per-step mode. - expect(result.llmCalls.reflectionSynth).toBe(3); - expect(result.llmCalls.alphaScoring).toBe(3); + expect(batchStates).toEqual([["a", "b"], ["c"]]); + expect(result.llmCalls.batchedReflection).toBe(2); + expect(result.llmCalls.reflectionSynth).toBe(0); + expect(result.llmCalls.alphaScoring).toBe(0); + + const rows = result.traceIds.map((id) => tmp.repos.traces.getById(id)!); + expect(rows.map((row) => row.reflection)).toEqual([ + "reflection a", + "reflection b", + "reflection c", + ]); }); it("long per-step downstream mode injects up to three following steps", async () => { @@ -368,7 +387,7 @@ describe("capture/pipeline (batched ρ+α path)", () => { }); const runner = buildRunner( { - batchMode: "auto", + batchMode: "per_step", batchThreshold: 2, reflectionContextMode: "task_downstream", longEpisodeReflectMode: "per_step_downstream", @@ -415,16 +434,27 @@ describe("capture/pipeline (batched ρ+α path)", () => { expect(step3Prompt).not.toContain("[step+3]"); }); - it("per_episode mode batches even when step count is large", async () => { - const scores = Array.from({ length: 5 }, (_, i) => ({ - idx: i, - reflection_text: `reflection #${i}`, - alpha: 0.4, - usable: true, - reason: "ok", - })); + it("per_episode mode chunk-batches when step count is large", async () => { + const chunkSizes: number[] = []; const llm = fakeLlm({ - completeJson: { [batchOp]: { scores } }, + completeJson: { + [batchOp]: (input) => { + const messages = input as Array<{ role: string; content: string }>; + const payload = JSON.parse(messages[messages.length - 1]!.content) as { + steps: Array<{ idx: number; state: string }>; + }; + chunkSizes.push(payload.steps.length); + return { + scores: payload.steps.map((step) => ({ + idx: step.idx, + reflection_text: `reflection ${step.state}`, + alpha: 0.4, + usable: true, + reason: "ok", + })), + }; + }, + }, }); const runner = buildRunner({ batchMode: "per_episode", batchThreshold: 2 }, llm); @@ -436,10 +466,111 @@ describe("capture/pipeline (batched ρ+α path)", () => { const ep = episodeSnapshot({ id: "ep_1", sessionId: "se_1", turns }); const result = await runCapture(runner, ep); expect(result.traceIds).toHaveLength(5); - expect(result.llmCalls.batchedReflection).toBe(1); + expect(chunkSizes).toEqual([2, 2, 1]); + expect(result.llmCalls.batchedReflection).toBe(3); expect(result.llmCalls.alphaScoring).toBe(0); }); + it("chunk-batch falls back to per-step only for the failed chunk", async () => { + const llm = fakeLlm({ + completeJson: { + [batchOp]: (input) => { + const messages = input as Array<{ role: string; content: string }>; + const payload = JSON.parse(messages[messages.length - 1]!.content) as { + steps: Array<{ idx: number; state: string }>; + }; + if (payload.steps[0]?.state === "q2") { + throw new Error("chunk failed"); + } + return { + scores: payload.steps.map((step) => ({ + idx: step.idx, + reflection_text: `batch ${step.state}`, + alpha: step.state === "q4" ? 0.5 : 0.2, + usable: true, + reason: "ok", + })), + }; + }, + [alphaOp]: { alpha: 0.9, usable: true, reason: "fallback" }, + }, + complete: { + "capture.reflection.synth": "per-step fallback reflection", + }, + }); + const runner = buildRunner({ batchMode: "auto", batchThreshold: 2 }, llm); + + const turns: EpisodeTurn[] = []; + for (let i = 0; i < 5; i++) { + turns.push(turn("user", `q${i}`, 1_000 + i * 100)); + turns.push(turn("assistant", `a${i}`, 1_050 + i * 100)); + } + const ep = episodeSnapshot({ id: "ep_1", sessionId: "se_1", turns }); + + const result = await runCapture(runner, ep); + expect(result.traceIds).toHaveLength(5); + expect(result.llmCalls.batchedReflection).toBe(2); + expect(result.llmCalls.reflectionSynth).toBe(2); + expect(result.llmCalls.alphaScoring).toBe(2); + expect(result.warnings.filter((w) => w.stage === "batch")).toHaveLength(1); + + const rows = result.traceIds.map((id) => tmp.repos.traces.getById(id)!); + expect(rows.map((row) => row.reflection)).toEqual([ + "batch q0", + "batch q1", + "per-step fallback reflection", + "per-step fallback reflection", + "batch q4", + ]); + expect(rows.map((row) => row.alpha)).toEqual([0.2, 0.2, 0.9, 0.9, 0.5]); + }); + + it("batch scorer passes an explicit maxTokens budget", async () => { + let seenMaxTokens: number | undefined; + const llm = fakeLlm({ + completeJson: { + [batchOp]: (_input, opts) => { + seenMaxTokens = (opts as { maxTokens?: number }).maxTokens; + return { + scores: [ + { + idx: 0, + reflection_text: "I made a useful choice.", + alpha: 0.5, + usable: true, + reason: "ok", + }, + ], + }; + }, + }, + }); + + await batchScoreReflections( + llm, + [ + { + step: { + key: "s1", + ts: 1_000 as EpochMs, + type: "text", + userText: "q", + agentText: "a", + agentThinking: null, + toolCalls: [], + rawReflection: null, + meta: {}, + }, + existingReflection: null, + }, + ], + { synthReflections: true }, + ); + + expect(seenMaxTokens).toBeGreaterThan(0); + expect(seenMaxTokens).toBeLessThan(16_384); + }); + it("malformed batched response → falls back to per-step + emits warning", async () => { const llm = fakeLlm({ completeJson: { From ded4c3fa10575be9cf706a0e45c19b18ba634891 Mon Sep 17 00:00:00 2001 From: GU TIANCHUN <96930846+TianchunGu@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:43:30 +0800 Subject: [PATCH 23/33] Fix #1897 recovery replay request storm (#1939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix #1897: fix(memos-local-plugin): add LLM circuit breaker for terminal provider errors Issue #1897 reported ~12,900 paid LLM requests in 24 h on Hermes against a DeepSeek key with insufficient balance. The local `system_model_status` row count (12,900) closely tracked the provider-side `request_count` (11,344) for the same billing window. The naming is misleading: `system_model_status` is not a health probe; it is the audit row written once per LLM call (ok / fallback / error) inside `core/llm/client.ts`. With no circuit breaker, every pipeline subscriber (capture / session-relation / reward / L2 / L3 / skill / retrieval LLM filter / world-model) kept firing on every turn / closed episode / induction, generating one paid request each. Add a per-`LlmClient` circuit breaker: - Trips on terminal errors: HTTP 401/402/403 or messages containing `insufficient balance` / `invalid api key` / `unauthorized` / `account suspended` / `billing`. - Open: short-circuits subsequent calls inside the facade without contacting the provider. Throws `MemosError(LLM_UNAVAILABLE)` with `details.circuitOpen=true` so existing catch blocks still work. - Half-open after cool-down (default 5 min, configurable, min 30 s): next call probes the provider; success closes the breaker, terminal failure re-opens it for another cool-down. - Host fallback rescues a call without tripping the breaker — fallback exists precisely to keep going when the primary is down. - Coalesces `system_model_status="circuit_open"` audit rows to at most one per ~25 s while the breaker stays open, so we don't replace paid spam with audit-row spam. - Exposes `circuitOpen` / `circuitOpenUntil` / `circuitOpenedReason` via `LlmClientStats` for the Overview viewer card. - Enabled by default; legacy behaviour available via `circuitBreaker.enabled = false`. Tests: 9 new vitest cases under `tests/unit/llm/client.test.ts` covering trip on 402, trip on "insufficient balance" message, no trip on generic transient, coalescing, half-open close on success, host-fallback rescues without trip, disabled mode, stats fields, and re-open on terminal probe failure. All 59 LLM and 28 pipeline tests pass; `tsc --noEmit` clean. Out of scope (tracked separately): 429 `Retry-After` handling (issue #1620), per-tool rate limits, daily budget caps. * Fix LLM breaker with host fallback * fix(plugin): bound recovery reflect replay LLM calls --------- Co-authored-by: autodev-bot Co-authored-by: Jiang <33757498+hijzy@users.noreply.github.com> Co-authored-by: jiachengzhen --- .../core/capture/capture.ts | 329 +++++++++++++++--- apps/memos-local-plugin/core/capture/types.ts | 13 + .../core/config/defaults.ts | 6 + apps/memos-local-plugin/core/config/schema.ts | 4 + .../tests/unit/capture/capture-batch.test.ts | 2 + .../tests/unit/capture/capture.test.ts | 134 ++++++- .../tests/unit/capture/normalizer.test.ts | 2 + 7 files changed, 437 insertions(+), 53 deletions(-) diff --git a/apps/memos-local-plugin/core/capture/capture.ts b/apps/memos-local-plugin/core/capture/capture.ts index bc9c7a812..576ba9060 100644 --- a/apps/memos-local-plugin/core/capture/capture.ts +++ b/apps/memos-local-plugin/core/capture/capture.ts @@ -33,7 +33,6 @@ import { extractSteps } from "./step-extractor.js"; import { createSummarizer, type Summarizer } from "./summarizer.js"; import { tagsForStep } from "./tagger.js"; import { extractErrorSignatures } from "./error-signature.js"; -import { RECOVERY_REASONS } from "../pipeline/recovery-constants.js"; import type { CaptureConfig, CaptureEvent, @@ -402,46 +401,60 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { const normalized = normalizeSteps(rawAll, deps.cfg); const normalizeMs = now() - normStart; - // Pair each normalized step with its already-persisted trace row - // (matched by ts). If runLite was skipped for any step, fall back - // to a fresh insert path so we don't lose data. - const existing = deps.tracesRepo.list({ episodeId: input.episode.id }); - const traceByTs = new Map(); - for (const tr of existing) traceByTs.set(tr.ts, tr); - const orphan = normalized.filter((s) => !traceByTs.has(s.ts)); - if (orphan.length > 0) { - // During dirty-reward recovery (recoverDirtyClosedEpisodes), the episode - // snapshot is rebuilt from trace_ids_json. Any "orphan" steps here are - // artifact mismatches between snapshot timestamps and DB rows — NOT - // genuinely missing traces. Inserting them would grow trace_ids_json, - // keep reward.traceCount !== traceIds.length, and restart the recovery - // loop on every bridge start. - if (input.episode.meta?.recoveryReason === RECOVERY_REASONS.DIRTY_REWARD_RESCORE) { - log.warn("reflect.orphan_steps_skipped_recovery", { - episodeId: input.episode.id, - count: orphan.length, - reason: "dirty_reward_rescore — skipping insert to break recovery loop", - }); - } else { - log.warn("reflect.orphan_steps", { - episodeId: input.episode.id, - count: orphan.length, - action: "fallback_insert", + // Pair each normalized step with its already-persisted trace row. + // Matching by timestamp alone is not stable during startup recovery: + // recovered snapshots are rebuilt from trace rows and the extractor may + // shift duplicate tool timestamps. Use content signatures first, and in + // recovered replay allow a timing-insensitive tool signature fallback. + const existing = deps.tracesRepo.listAllForEpisode(input.episode.id); + const recoveredReplay = isRecoveredReplay(input.episode); + const matcher = createTraceMatcher(existing, { allowRelaxedToolTiming: recoveredReplay }); + const matchedRows = normalized.map((s) => matcher.take(s)); + const orphanEntries = normalized + .map((step, index) => ({ step, index })) + .filter(({ index }) => matchedRows[index] === null); + if (orphanEntries.length > 0) { + log.warn("reflect.orphan_steps", { + episodeId: input.episode.id, + count: orphanEntries.length, + action: recoveredReplay ? "skip_recovery_insert" : "fallback_insert", + }); + const maxRecoveryOrphans = Math.max(0, Math.floor(deps.cfg.maxRecoveryOrphanInserts)); + const insertableEntries = recoveredReplay + ? orphanEntries.slice(0, maxRecoveryOrphans) + : orphanEntries; + const skipped = orphanEntries.length - insertableEntries.length; + if (skipped > 0) { + warnings.push({ + stage: "persist", + message: "skipped recovered orphan trace inserts to avoid replay duplicates", + detail: { + episodeId: input.episode.id, + skipped, + maxRecoveryOrphanInserts: maxRecoveryOrphans, + }, }); - // These steps never went through runLite (likely a test path or a - // dropped event). Insert them now with reflection=null so the - // batch pass below can patch them like the rest. + } + // These steps never went through runLite (likely a test path or a + // dropped event). Insert them now with reflection=null so the + // batch pass below can patch them like the rest. + if (insertableEntries.length > 0) { + const orphan = insertableEntries.map(({ step }) => step); const summStart = now(); - const { summaries } = await runSummarize( - orphan.map((s) => ({ - ...s, - reflection: { text: null, alpha: 0, usable: false, source: "none" }, - })), - summStart, - llmCalls, - warnings, - { episodeId: input.episode.id, phase: "reflect" }, - ); + const { summaries } = recoveredReplay + ? { + summaries: orphan.map(heuristicTraceSummary), + } + : await runSummarize( + orphan.map((s) => ({ + ...s, + reflection: { text: null, alpha: 0, usable: false, source: "none" }, + })), + summStart, + llmCalls, + warnings, + { episodeId: input.episode.id, phase: "reflect" }, + ); const orphanScored: ScoredStep[] = orphan.map((s) => ({ ...s, reflection: { text: null, alpha: 0, usable: false, source: "none" }, @@ -449,7 +462,10 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { const { vecs } = await runEmbed(orphanScored, summaries, warnings); const orphanRows = buildRows(orphanScored, summaries, vecs, input.episode); await persistRows(orphanRows, input, warnings); - for (const r of orphanRows) traceByTs.set(r.ts, r); + orphanRows.forEach((row, i) => { + const entry = insertableEntries[i]; + if (entry) matchedRows[entry.index] = row; + }); } } @@ -492,11 +508,30 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { taskSummary: taskSummary ? taskSummary.slice(0, 240) : null, }); let scored: ScoredStep[] = []; + const reflectBudget = createReflectLlmBudget(deps.cfg.maxReflectLlmCalls, warnings, input.episode.id); if (scoringPlan === "batch") { - scored = await runBatchScoring(normalized, rLlm!, deps, warnings, llmCalls, input.episode.id, taskSummary); + scored = await runBatchScoring( + normalized, + rLlm!, + deps, + warnings, + llmCalls, + input.episode.id, + taskSummary, + reflectBudget, + ); } if (scoringPlan === "chunk_batch") { - scored = await runChunkedBatchScoring(normalized, rLlm!, deps, warnings, llmCalls, input.episode.id, taskSummary); + scored = await runChunkedBatchScoring( + normalized, + rLlm!, + deps, + warnings, + llmCalls, + input.episode.id, + taskSummary, + reflectBudget, + ); } if (scoringPlan === "per_step" || scored.length === 0) { scored = await runPerStepScoring( @@ -507,6 +542,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { llmCalls, input.episode.id, buildReflectionContexts(normalized, taskSummary, downstreamByStep), + reflectBudget, ); } const reflectMs = now() - reflectStart; @@ -516,12 +552,13 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { // orphan-fallback above) are skipped with a warning. const persistStart = now(); const patchedTraceIds: string[] = []; - for (const s of scored) { - const row = traceByTs.get(s.ts); + for (let i = 0; i < scored.length; i++) { + const s = scored[i]!; + const row = matchedRows[i]; if (!row) { warnings.push({ stage: "persist", - message: "reflect: no trace row for step ts; skipping", + message: "reflect: no trace row for step signature; skipping", detail: { ts: s.ts, key: s.key }, }); continue; @@ -561,8 +598,8 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { // assignment). For reflect-phase rows we re-emit ScoredStep-shaped // candidates carrying the freshly computed reflection + α; the // already-existing trace ids come from the matched DB rows. - const traces: TraceCandidate[] = scored.map((s) => { - const row = traceByTs.get(s.ts); + const traces: TraceCandidate[] = scored.map((s, i) => { + const row = matchedRows[i]; return { ...s, traceId: (row?.id ?? "") as TraceId, @@ -916,6 +953,65 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { return out; } + function createTraceMatcher( + rows: TraceRow[], + opts: { allowRelaxedToolTiming: boolean }, + ): { take(step: StepCandidate): TraceRow | null } { + const exact = indexRows(rows, traceIdentitySignature); + const relaxed = opts.allowRelaxedToolTiming + ? indexRows(rows, traceRelaxedIdentitySignature) + : new Map(); + const used = new Set(); + + function takeFrom(index: Map, signature: string): TraceRow | null { + const candidates = index.get(signature) ?? []; + for (const row of candidates) { + if (used.has(row.id)) continue; + used.add(row.id); + return row; + } + return null; + } + + return { + take(step) { + const exactMatch = takeFrom(exact, stepIdentitySignature(step)); + if (exactMatch) return exactMatch; + if (!opts.allowRelaxedToolTiming) return null; + return takeFrom(relaxed, stepRelaxedIdentitySignature(step)); + }, + }; + } + + function indexRows( + rows: TraceRow[], + signatureOf: (row: TraceRow) => string, + ): Map { + const out = new Map(); + for (const row of rows) { + const signature = signatureOf(row); + const bucket = out.get(signature); + if (bucket) bucket.push(row); + else out.set(signature, [row]); + } + return out; + } + + function isRecoveredReplay(episode: CaptureInput["episode"]): boolean { + const meta = episode.meta ?? {}; + return Boolean(meta.recoveredAtStartup) || typeof meta.recoveryReason === "string"; + } + + function heuristicTraceSummary(step: NormalizedStep): string { + const tool = step.toolCalls[0]; + const base = firstNonEmpty([ + step.userText, + step.agentText, + tool ? `Tool ${tool.name}` : "", + ]) || "(empty turn)"; + return base.replace(/\s+/g, " ").trim().slice(0, 140); + } + function stepIdentitySignature(step: StepCandidate): string { const tool = step.toolCalls[0]; const turnId = pickTurnId(step.meta, step.ts); @@ -939,6 +1035,25 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { return ["user", turnId, step.ts, step.userText.trim()].join("\x1f"); } + function stepRelaxedIdentitySignature(step: StepCandidate): string { + const tool = step.toolCalls[0]; + const turnId = pickTurnId(step.meta, step.ts); + if (tool) { + return [ + "tool", + turnId, + tool.name, + stableJson(tool.input), + stableJson(tool.output), + tool.errorCode ?? "", + ].join("\x1f"); + } + if (step.agentText.trim()) { + return ["assistant", turnId, step.agentText.trim()].join("\x1f"); + } + return ["user", turnId, step.userText.trim()].join("\x1f"); + } + function traceIdentitySignature( row: Pick, ): string { @@ -963,6 +1078,24 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { return ["user", row.turnId, row.ts, row.userText.trim()].join("\x1f"); } + function traceRelaxedIdentitySignature(row: TraceRow): string { + const tool = row.toolCalls[0]; + if (tool) { + return [ + "tool", + row.turnId, + tool.name, + stableJson(tool.input), + stableJson(tool.output), + tool.errorCode ?? "", + ].join("\x1f"); + } + if (row.agentText.trim()) { + return ["assistant", row.turnId, row.agentText.trim()].join("\x1f"); + } + return ["user", row.turnId, row.userText.trim()].join("\x1f"); + } + function stableJson(value: unknown): string { if (value === undefined) return ""; return JSON.stringify(sortJson(value)); @@ -1120,12 +1253,15 @@ async function runBatchScoring( llmCalls: { reflectionSynth: number; alphaScoring: number; batchedReflection: number }, episodeId: string, taskSummary: string | null, + budget: ReflectLlmBudget, ): Promise { const inputs: BatchScoreInput[] = normalized.map((step) => ({ step, existingReflection: extractReflection(step), })); + if (!budget.tryUse("batch")) return []; + try { const out = await batchScoreReflections(llm, inputs, { synthReflections: deps.cfg.synthReflections, @@ -1139,6 +1275,7 @@ async function runBatchScoring( reflection: out.scores[i] ?? disabledScore(null, "none"), })); } catch (err) { + budget.stopIfTerminal(err, "batch"); // Single failure mode: the batched call (or its validator) threw. // Fall back to per-step in the caller. We surface a warning so the // viewer can show "batch path degraded" without crashing capture. @@ -1159,6 +1296,7 @@ async function runChunkedBatchScoring( llmCalls: { reflectionSynth: number; alphaScoring: number; batchedReflection: number }, episodeId: string, taskSummary: string | null, + budget: ReflectLlmBudget, ): Promise { const chunkSize = Math.max(1, deps.cfg.batchThreshold); const chunks: NormalizedStep[][] = []; @@ -1167,7 +1305,16 @@ async function runChunkedBatchScoring( } const concurrency = Math.max(1, deps.cfg.llmConcurrency); const scoredChunks = await runConcurrently(chunks, concurrency, async (chunk): Promise => { - const scored = await runBatchScoring(chunk, llm, deps, warnings, llmCalls, episodeId, taskSummary); + const scored = await runBatchScoring( + chunk, + llm, + deps, + warnings, + llmCalls, + episodeId, + taskSummary, + budget, + ); if (scored.length > 0) return scored; return runPerStepScoring( chunk, @@ -1177,6 +1324,7 @@ async function runChunkedBatchScoring( llmCalls, episodeId, buildReflectionContexts(chunk, taskSummary, chunk.map(() => [])), + budget, ); }); return scoredChunks.flat(); @@ -1190,13 +1338,14 @@ async function runPerStepScoring( llmCalls: { reflectionSynth: number; alphaScoring: number }, episodeId: string, contexts: ReflectionContext[], + budget: ReflectLlmBudget, ): Promise { const concurrency = Math.max(1, deps.cfg.llmConcurrency); return runConcurrently(normalized, concurrency, async (step, idx): Promise => { const context = contexts[idx] ?? {}; - const { score, synthCount } = await resolveReflection(step, llm, deps, warnings, episodeId, context); + const { score, synthCount } = await resolveReflection(step, llm, deps, warnings, episodeId, context, budget); llmCalls.reflectionSynth += synthCount; - const finalScore = await resolveAlpha(step, score, llm, deps, warnings, episodeId, context); + const finalScore = await resolveAlpha(step, score, llm, deps, warnings, episodeId, context, budget); if (finalScore !== score) llmCalls.alphaScoring += 1; return { ...step, reflection: finalScore }; }); @@ -1209,6 +1358,7 @@ async function resolveReflection( warnings: CaptureResult["warnings"], episodeId: string, context: ReflectionContext, + budget: ReflectLlmBudget, ): Promise<{ score: ReflectionScore; synthCount: number }> { const adapterProvided = step.rawReflection !== null && step.rawReflection.trim().length > 0; const extracted = extractReflection(step); @@ -1221,6 +1371,9 @@ async function resolveReflection( if (!deps.cfg.synthReflections || !llm) { return { score: disabledScore(null, "none"), synthCount: 0 }; } + if (!budget.tryUse("reflection.synth")) { + return { score: disabledScore(null, "none"), synthCount: 0 }; + } try { const synth = await synthesizeReflection(llm, step, { episodeId, @@ -1237,6 +1390,7 @@ async function resolveReflection( } return { score: disabledScore(null, "none"), synthCount: 1 }; } catch (err) { + budget.stopIfTerminal(err, "reflection.synth"); warnings.push({ stage: "reflection.synth", message: "synth failed", @@ -1254,9 +1408,11 @@ async function resolveAlpha( warnings: CaptureResult["warnings"], episodeId: string, context: ReflectionContext, + budget: ReflectLlmBudget, ): Promise { if (!current.text) return current; // nothing to grade if (!deps.cfg.alphaScoring || !llm) return current; + if (!budget.tryUse("alpha")) return current; try { const scored = await scoreReflection(llm, { @@ -1276,6 +1432,7 @@ async function resolveAlpha( model: scored.model, }; } catch (err) { + budget.stopIfTerminal(err, "alpha"); warnings.push({ stage: "alpha", message: "alpha scoring failed; keeping neutral α", @@ -1307,6 +1464,78 @@ async function runConcurrently( return out; } +interface ReflectLlmBudget { + tryUse(stage: string): boolean; + stopIfTerminal(err: unknown, stage: string): void; +} + +function createReflectLlmBudget( + configuredLimit: number, + warnings: CaptureResult["warnings"], + episodeId: string, +): ReflectLlmBudget { + const limit = Math.max(0, Math.floor(configuredLimit)); + let used = 0; + let stopped = false; + let exhaustedWarned = false; + let terminalWarned = false; + + return { + tryUse(stage) { + if (stopped || used >= limit) { + if (!exhaustedWarned) { + exhaustedWarned = true; + warnings.push({ + stage, + message: "reflect LLM budget exhausted; using non-LLM fallback for remaining steps", + detail: { episodeId, limit, used, stopped }, + }); + } + return false; + } + used += 1; + return true; + }, + stopIfTerminal(err, stage) { + if (!isTerminalReflectLlmError(err)) return; + stopped = true; + if (terminalWarned) return; + terminalWarned = true; + warnings.push({ + stage, + message: "terminal reflect LLM error; stopped remaining reflect LLM calls", + detail: { episodeId, limit, used, ...errDetail(err) }, + }); + }, + }; +} + +function isTerminalReflectLlmError(err: unknown): boolean { + if (!(err instanceof MemosError)) { + const msg = err instanceof Error ? err.message : String(err); + return terminalMessage(msg); + } + if (err.code !== ERROR_CODES.LLM_UNAVAILABLE) return terminalMessage(err.message); + const details = (err.details ?? {}) as Record; + if (details.circuitOpen === true) return true; + const status = Number(details.status); + if (status === 401 || status === 402 || status === 403) return true; + return terminalMessage(err.message); +} + +function terminalMessage(message: string): boolean { + const msg = message.toLowerCase(); + return ( + msg.includes("circuit_open") || + msg.includes("insufficient balance") || + msg.includes("invalid api key") || + msg.includes("invalid_api_key") || + msg.includes("unauthorized") || + msg.includes("account suspended") || + msg.includes("billing") + ); +} + function errDetail(err: unknown): Record { if (err instanceof MemosError) return { code: err.code, message: err.message, ...(err.details ?? {}) }; if (err instanceof Error) return { name: err.name, message: err.message }; diff --git a/apps/memos-local-plugin/core/capture/types.ts b/apps/memos-local-plugin/core/capture/types.ts index efdc637f2..5e37c4448 100644 --- a/apps/memos-local-plugin/core/capture/types.ts +++ b/apps/memos-local-plugin/core/capture/types.ts @@ -193,6 +193,19 @@ export interface CaptureConfig { alphaScoring: boolean; synthReflections: boolean; llmConcurrency: number; + /** + * Hard cap for LLM calls made by one topic-end reflect pass. This bounds + * startup recovery / dirty-episode replay so a single large episode cannot + * generate unbounded paid requests. + */ + maxReflectLlmCalls: number; + /** + * Startup-recovered episodes are reconstructed from already-persisted + * traces. Any "orphan" during that replay is usually a matching drift, not + * genuinely missing content. Keep inserts disabled by default to avoid + * duplicating historical rows while still allowing operators to opt in. + */ + maxRecoveryOrphanInserts: number; /** * V7 §3.2 batched variant. Controls when reflection synthesis + α scoring * collapse into ONE LLM call per episode instead of N per-step calls. diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index c4756f1d2..8a8a0cd0b 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -100,6 +100,12 @@ export const DEFAULT_CONFIG: ResolvedConfig = { // still contribute useful α values. synthReflections: true, llmConcurrency: 4, + // Bound topic-end reflect work so dirty startup recovery cannot replay + // a huge historical episode into thousands of paid LLM calls. + maxReflectLlmCalls: 128, + // Recovered episodes are reconstructed from persisted traces; replay + // orphans are usually matching drift, so do not insert duplicate rows. + maxRecoveryOrphanInserts: 0, // V7 §3.2 batched variant. With "auto" we issue a single LLM call // per episode for both reflection synth and α scoring as long as // the episode is short enough — this collapses 2N per-step calls diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 1e96b95ea..4d8e19262 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -168,6 +168,10 @@ const AlgorithmSchema = Type.Object({ synthReflections: Bool(false), /** Concurrency for α scoring + synth LLM calls (per_step mode only). */ llmConcurrency: NumberInRange(4, 1, 32), + /** Hard cap for one topic-end reflect pass, including recovery replay. */ + maxReflectLlmCalls: NumberInRange(128, 0, 10_000), + /** Max orphan trace inserts allowed during startup-recovered replay. */ + maxRecoveryOrphanInserts: NumberInRange(0, 0, 10_000), /** * V7 §3.2 batched variant. When/how to fold per-step reflection synth + * α scoring into one episode-level LLM call: diff --git a/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts b/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts index bc4b76f28..5a141611f 100644 --- a/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts @@ -81,6 +81,8 @@ function baseConfig(overrides: Partial = {}): CaptureConfig { alphaScoring: true, synthReflections: true, llmConcurrency: 2, + maxReflectLlmCalls: 128, + maxRecoveryOrphanInserts: 0, batchMode: "auto", batchThreshold: 12, reflectionContextMode: "none", diff --git a/apps/memos-local-plugin/tests/unit/capture/capture.test.ts b/apps/memos-local-plugin/tests/unit/capture/capture.test.ts index 95d728338..b30cbe4c1 100644 --- a/apps/memos-local-plugin/tests/unit/capture/capture.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/capture.test.ts @@ -87,6 +87,8 @@ function baseConfig(overrides: Partial = {}): CaptureConfig { alphaScoring: true, synthReflections: false, llmConcurrency: 2, + maxReflectLlmCalls: 128, + maxRecoveryOrphanInserts: 0, // Default to per-step here so the existing assertions on // `llmCalls.alphaScoring`/`reflectionSynth` continue to hold. The // batched path has its own dedicated test file. @@ -133,15 +135,18 @@ function episodeSnapshot(opts: { function traceRow(opts: { id: string; + episodeId?: string; + sessionId?: string; ts: number; + turnId?: number; userText?: string; agentText?: string; toolCalls?: TraceRow["toolCalls"]; }): TraceRow { return { id: opts.id as TraceId, - episodeId: "ep_1" as EpisodeId, - sessionId: "se_1" as SessionId, + episodeId: (opts.episodeId ?? "ep_1") as EpisodeId, + sessionId: (opts.sessionId ?? "se_1") as SessionId, ts: opts.ts as EpochMs, userText: opts.userText ?? "", agentText: opts.agentText ?? "", @@ -157,7 +162,7 @@ function traceRow(opts: { errorSignatures: [], vecSummary: null, vecAction: null, - turnId: 1_000 as EpochMs, + turnId: (opts.turnId ?? 1_000) as EpochMs, schemaVersion: 1, }; } @@ -217,6 +222,129 @@ describe("capture/pipeline (end-to-end)", () => { }); } + it("recovered replay matches tool traces by payload when timestamps drift", async () => { + tmp.repos.traces.insert(traceRow({ + id: "tr_tool", + ts: 1_001, + turnId: 1_000, + userText: "run search", + toolCalls: [{ + name: "search", + input: { q: "memos" }, + output: "ok", + }], + })); + tmp.repos.traces.insert(traceRow({ + id: "tr_response", + ts: 1_003, + turnId: 1_000, + agentText: "done", + })); + + const runner = buildRunner({ + alphaScoring: false, + synthReflections: false, + embedTraces: false, + }, null, null); + const ep = episodeSnapshot({ + id: "ep_1", + sessionId: "se_1", + turns: [ + turn("user", "run search", 1_000), + turn("tool", "ok", 1_002, { + name: "search", + input: { q: "memos" }, + output: "ok", + }), + turn("assistant", "done", 1_003), + ], + }); + ep.meta = { recoveredAtStartup: 1_004, recoveryReason: "dirty_reward_rescore" }; + + const result = await runner.runReflect({ episode: ep, closedBy: "finalized" }); + + expect(result.traceIds).toHaveLength(2); + expect(result.warnings.some((w) => w.message.includes("skipped recovered orphan"))).toBe(false); + expect(tmp.repos.traces.listAllForEpisode("ep_1" as EpisodeId)).toHaveLength(2); + }); + + it("does not insert replay orphans for startup-recovered episodes by default", async () => { + const runner = buildRunner({ + alphaScoring: false, + synthReflections: false, + embedTraces: false, + }, null, null); + const ep = episodeSnapshot({ + id: "ep_1", + sessionId: "se_1", + turns: [ + turn("user", "hello", 1_000), + turn("assistant", "hi", 1_010), + ], + }); + ep.meta = { recoveredAtStartup: 1_020, recoveryReason: "dirty_reward_rescore" }; + + const result = await runner.runReflect({ episode: ep, closedBy: "finalized" }); + + expect(tmp.repos.traces.listAllForEpisode("ep_1" as EpisodeId)).toHaveLength(0); + expect(result.traceIds).toHaveLength(0); + expect(result.warnings.some((w) => w.message.includes("skipped recovered orphan"))).toBe(true); + }); + + it("caps reflect-phase LLM calls per episode", async () => { + tmp.repos.traces.insert(traceRow({ + id: "tr_1", + ts: 1_100, + turnId: 1_000, + userText: "u1", + agentText: "a1", + })); + tmp.repos.traces.insert(traceRow({ + id: "tr_2", + ts: 2_100, + turnId: 2_000, + userText: "u2", + agentText: "a2", + })); + tmp.repos.traces.insert(traceRow({ + id: "tr_3", + ts: 3_100, + turnId: 3_000, + userText: "u3", + agentText: "a3", + })); + const llm = fakeLlm({ + complete: { + "capture.reflection.synth": "I chose the next action for this task.", + }, + }); + const runner = buildRunner({ + alphaScoring: false, + synthReflections: true, + embedTraces: false, + batchMode: "per_step", + maxReflectLlmCalls: 1, + }, llm, null); + const ep = episodeSnapshot({ + id: "ep_1", + sessionId: "se_1", + turns: [ + turn("user", "u1", 1_000), + turn("assistant", "a1", 1_100), + turn("user", "u2", 2_000), + turn("assistant", "a2", 2_100), + turn("user", "u3", 3_000), + turn("assistant", "a3", 3_100), + ], + }); + + const result = await runner.runReflect({ episode: ep, closedBy: "finalized" }); + + expect(result.llmCalls.reflectionSynth).toBe(1); + expect(result.warnings.some((w) => w.message.includes("reflect LLM budget exhausted"))).toBe(true); + expect(llm.stats().requests).toBe(1); + }); + it("lightweight capture merges one turn into one memory with summary-only embedding", async () => { const llm = fakeLlm({ completeJson: { diff --git a/apps/memos-local-plugin/tests/unit/capture/normalizer.test.ts b/apps/memos-local-plugin/tests/unit/capture/normalizer.test.ts index a09f7d1f5..3cd0877ac 100644 --- a/apps/memos-local-plugin/tests/unit/capture/normalizer.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/normalizer.test.ts @@ -11,6 +11,8 @@ const cfg: CaptureConfig = { alphaScoring: false, synthReflections: false, llmConcurrency: 1, + maxReflectLlmCalls: 128, + maxRecoveryOrphanInserts: 0, batchMode: "per_step", batchThreshold: 12, }; From e9ddaeb068eff2993578236665a586397905b6ba Mon Sep 17 00:00:00 2001 From: de1ty <7804799+de1tydev@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:44:03 +0800 Subject: [PATCH 24/33] fix: guard against episode storm stalling foreground sessions (#1844) fix: guard against episode storm stalling foreground sessions (#1755) Co-authored-by: jiachengzhen --- .../core/config/defaults.ts | 3 + apps/memos-local-plugin/core/config/schema.ts | 17 ++ apps/memos-local-plugin/core/pipeline/deps.ts | 34 ++-- .../core/pipeline/orchestrator.ts | 150 +++++++++++++----- .../memos-local-plugin/core/pipeline/types.ts | 12 ++ .../core/util/rate-limited-llm.ts | 88 ++++++++++ .../memos-local-plugin/core/util/semaphore.ts | 30 ++++ 7 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 apps/memos-local-plugin/core/util/rate-limited-llm.ts create mode 100644 apps/memos-local-plugin/core/util/semaphore.ts diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 8a8a0cd0b..5c9dff305 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -255,6 +255,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { session: { followUpMode: "merge_follow_ups", mergeMaxGapMs: 2 * 60 * 60 * 1000, + maxTurnsPerEpisode: 30, + classifyTimeoutMs: 5000, + bgLlmConcurrency: 2, }, retrieval: { tier1TopK: 3, diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 4d8e19262..8566f90f3 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -402,6 +402,23 @@ const AlgorithmSchema = Type.Object({ * `taskIdleTimeoutMs`. */ mergeMaxGapMs: NumberInRange(2 * 60 * 60 * 1000, 0, 24 * 60 * 60 * 1000), + /** + * Hard cap on turns in a merged episode. Once reached, the next + * turn forces a topic boundary even if relation classification says + * follow-up/revision. Keeps task-end processing bounded. + */ + maxTurnsPerEpisode: NumberInRange(30, 5, 200), + /** + * Max time to wait for relation classification before defaulting + * to a conservative new-task boundary so foreground prompt + * construction cannot stall indefinitely. + */ + classifyTimeoutMs: NumberInRange(5000, 1000, 30000), + /** + * Shared LLM concurrency budget for asynchronous background + * capture/reward/L2/L3/skill-evolution processing. + */ + bgLlmConcurrency: NumberInRange(2, 1, 8), }, { default: {} }), retrieval: Type.Object({ /** How many Skill snippets to inject at turn start. */ diff --git a/apps/memos-local-plugin/core/pipeline/deps.ts b/apps/memos-local-plugin/core/pipeline/deps.ts index decf974d0..c4d3dd413 100644 --- a/apps/memos-local-plugin/core/pipeline/deps.ts +++ b/apps/memos-local-plugin/core/pipeline/deps.ts @@ -99,6 +99,8 @@ import type { PipelineSubscriptions, } from "./types.js"; import { wrapRetrievalRepos } from "./retrieval-repos.js"; +import { createSemaphore } from "../util/semaphore.js"; +import { rateLimitLlmClient } from "../util/rate-limited-llm.js"; // ─── Algorithm config slice helper ──────────────────────────────────────── @@ -167,6 +169,9 @@ export function extractAlgorithmConfig( session: { followUpMode: alg.session.followUpMode, mergeMaxGapMs: alg.session.mergeMaxGapMs, + maxTurnsPerEpisode: alg.session.maxTurnsPerEpisode, + classifyTimeoutMs: alg.session.classifyTimeoutMs, + bgLlmConcurrency: alg.session.bgLlmConcurrency, }, }; } @@ -205,6 +210,10 @@ export function buildPipelineSubscribers( session?: PipelineSessionSet, ): PipelineSubscriberSet { const log = deps.log ?? rootLogger.child({ channel: "core.pipeline" }); + const bgLlmSemaphore = createSemaphore(algorithm.session.bgLlmConcurrency); + const bgLlm = rateLimitLlmClient(deps.llm, bgLlmSemaphore); + const bgReflectLlm = rateLimitLlmClient(deps.reflectLlm, bgLlmSemaphore); + const bgL3Llm = rateLimitLlmClient(deps.l3Llm ?? deps.llm, bgLlmSemaphore); const lightweightMode = algorithm.lightweightMemory.enabled; const captureRunner = createCaptureRunner({ @@ -212,8 +221,8 @@ export function buildPipelineSubscribers( embeddingRetryQueue: deps.repos.embeddingRetryQueue, episodesRepo: adaptEpisodesRepo(deps.repos.episodes), embedder: deps.embedder, - llm: deps.llm, - reflectLlm: deps.reflectLlm, + llm: bgLlm, + reflectLlm: bgReflectLlm, bus: buses.capture, cfg: algorithm.capture, now: deps.now, @@ -249,14 +258,14 @@ export function buildPipelineSubscribers( tracesRepo: deps.repos.traces, episodesRepo: deps.repos.episodes, feedbackRepo: deps.repos.feedback, - llm: deps.llm, + llm: bgLlm, bus: buses.reward, cfg: algorithm.reward, evaluator: { - reflectionProvider: deps.reflectLlm?.provider, - reflectionModel: deps.reflectLlm?.model, - scorerProvider: deps.llm?.provider, - scorerModel: deps.llm?.model, + reflectionProvider: bgReflectLlm?.provider, + reflectionModel: bgReflectLlm?.model, + scorerProvider: bgLlm?.provider, + scorerModel: bgLlm?.model, }, now: deps.now, // Wire the live episode snapshot so the R_human scorer sees the @@ -288,7 +297,7 @@ export function buildPipelineSubscribers( repos: deps.repos, rewardBus: buses.reward, l2Bus: buses.l2, - llm: deps.llm, + llm: bgLlm, log: log.child({ channel: "core.memory.l2" }), config: algorithm.l2Induction, thresholds: { @@ -303,8 +312,9 @@ export function buildPipelineSubscribers( l2Bus: buses.l2, l3Bus: buses.l3, // Dedicated L3 model when `config.l3Llm.*` is set; otherwise the - // bootstrap aliases `l3Llm` to the main `llm`, so this is a no-op. - llm: deps.l3Llm ?? deps.llm, + // bootstrap aliases `l3Llm` to the main `llm`. Both paths share + // the same background concurrency budget as the other subscribers. + llm: bgL3Llm, log: log.child({ channel: "core.memory.l3" }), config: algorithm.l3Abstraction, }); @@ -312,7 +322,7 @@ export function buildPipelineSubscribers( const skillHandle = attachSkillSubscriber({ repos: deps.repos, embedder: deps.embedder, - llm: deps.llm, + llm: bgLlm, bus: buses.skill, l2Bus: buses.l2, rewardBus: buses.reward, @@ -322,7 +332,7 @@ export function buildPipelineSubscribers( const feedbackHandle = attachFeedbackSubscriber({ repos: deps.repos, - llm: deps.llm, + llm: bgLlm, embedder: deps.embedder, bus: buses.feedback, log: log.child({ channel: "core.feedback" }), diff --git a/apps/memos-local-plugin/core/pipeline/orchestrator.ts b/apps/memos-local-plugin/core/pipeline/orchestrator.ts index c39bc6309..62e44e017 100644 --- a/apps/memos-local-plugin/core/pipeline/orchestrator.ts +++ b/apps/memos-local-plugin/core/pipeline/orchestrator.ts @@ -83,6 +83,30 @@ import { createEmbeddingRetryWorker, systemErrorEvent } from "../embedding/index import type { EpisodeSnapshot } from "../session/index.js"; import type { IntentDecision, RelationDecision, TurnRelation } from "../session/types.js"; +function classifyWithTimeout( + classifyFn: () => Promise, + timeoutMs: number, + log: Logger, +): Promise { + return Promise.race([ + classifyFn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("classify_timeout")), timeoutMs), + ), + ]).catch((err) => { + log.warn("relation.classify_timeout", { + timeoutMs, + err: err instanceof Error ? err.message : String(err), + }); + return { + relation: "new_task" as const, + confidence: 0, + reason: "classify_timeout", + signals: ["classify_timeout"], + }; + }); +} + // ─── Factory ────────────────────────────────────────────────────────────── export function createPipeline(deps: PipelineDeps): PipelineHandle { @@ -419,13 +443,17 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { const gapMs = Math.max(0, (turnTs ?? now()) - lastTurnTs); const relationStartedAt = Date.now(); - const decision = await session.relation.classify({ - prevUserText: ctx.prevUserText, - prevAssistantText: ctx.prevAssistantText, - newUserText: userText, - gapMs, - prevEpisodeId: currentEpId, - }); + const decision = await classifyWithTimeout( + () => session.relation.classify({ + prevUserText: ctx.prevUserText, + prevAssistantText: ctx.prevAssistantText, + newUserText: userText, + gapMs, + prevEpisodeId: currentEpId, + }), + algorithm.session.classifyTimeoutMs, + log, + ); const relationDurationMs = Math.max(0, Date.now() - relationStartedAt); log.info("relation.classified", { @@ -472,7 +500,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { durationMs: relationDurationMs, }); - if (keepAppending) { + if (keepAppending && open.turns.length < algorithm.session.maxTurnsPerEpisode) { // Same topic — just append the new user turn to the open // episode. No finalize, no reflect; that's deferred until // the user actually changes topic / closes the session. @@ -489,8 +517,19 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { return { episode: open, sessionId, relation: decision.relation }; } + if (keepAppending) { + log.info("episode.turn_limit_reached", { + sessionId, + episodeId: currentEpId, + turns: open.turns.length, + maxTurnsPerEpisode: algorithm.session.maxTurnsPerEpisode, + relation: decision.relation, + source: "open_episode", + }); + } + // Topic changed (new_task) OR gap too large OR - // episode_per_turn mode — finalize the open episode, which + // episode_per_turn mode OR turn limit reached — finalize the open episode, which // fires `episode.finalized` → captureSubscriber.runReflect → // R_human + V backprop. Fire-and-forget; the chain runs on // its own clock (tests can drive it via `flush()`). @@ -584,13 +623,17 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { } } else { const relationStartedAt = Date.now(); - const decision = await session.relation.classify({ - prevUserText: ctx.prevUserText, - prevAssistantText: ctx.prevAssistantText, - newUserText: userText, - gapMs, - prevEpisodeId: snapshot.id as EpisodeId, - }); + const decision = await classifyWithTimeout( + () => session.relation.classify({ + prevUserText: ctx.prevUserText, + prevAssistantText: ctx.prevAssistantText, + newUserText: userText, + gapMs, + prevEpisodeId: snapshot.id as EpisodeId, + }), + algorithm.session.classifyTimeoutMs, + log, + ); const relationDurationMs = Math.max(0, Date.now() - relationStartedAt); log.info("relation.classified", { @@ -635,7 +678,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { durationMs: relationDurationMs, }); - if (keepAppending) { + if (keepAppending && snapshot.turns.length < algorithm.session.maxTurnsPerEpisode) { if (snapshot.status === "closed") { session.sessionManager.reopenEpisode( snapshot.id as EpisodeId, @@ -662,6 +705,17 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { }; } + if (keepAppending) { + log.info("episode.turn_limit_reached", { + sessionId, + episodeId: snapshot.id, + turns: snapshot.turns.length, + maxTurnsPerEpisode: algorithm.session.maxTurnsPerEpisode, + relation: decision.relation, + source: "recovered_open_topic", + }); + } + if (snapshot.status === "open") { session.sessionManager.finalizeEpisode(snapshot.id as EpisodeId, { patchMeta: { @@ -686,13 +740,17 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { const gapMs = Math.max(0, (turnTs ?? now()) - prev.endedAt); const relationStartedAt = Date.now(); - const decision = await session.relation.classify({ - prevUserText: prev.userText, - prevAssistantText: prev.assistantText, - newUserText: userText, - gapMs, - prevEpisodeId: prev.episodeId, - }); + const decision = await classifyWithTimeout( + () => session.relation.classify({ + prevUserText: prev.userText, + prevAssistantText: prev.assistantText, + newUserText: userText, + gapMs, + prevEpisodeId: prev.episodeId, + }), + algorithm.session.classifyTimeoutMs, + log, + ); const relationDurationMs = Math.max(0, Date.now() - relationStartedAt); log.info("relation.classified", { @@ -738,22 +796,34 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { }); if (shouldReopen) { - const reopenReason = - decision.relation === "revision" ? "revision" : "follow_up"; - const snap = session.sessionManager.reopenEpisode(prev.episodeId, reopenReason); - session.sessionManager.addTurn(prev.episodeId, { - role: "user", - content: userText, - ts: turnTs, - meta: { - source: reopenReason, - classifiedRelation: decision.relation, - ...meta, - }, - }); - openEpisodeBySession.set(sessionId, prev.episodeId); - lastEpisodeBySession.delete(sessionId); - return { episode: snap, sessionId, relation: decision.relation }; + const prevSnap = session.sessionManager.getEpisode(prev.episodeId); + if (prevSnap && prevSnap.turns.length >= algorithm.session.maxTurnsPerEpisode) { + log.info("episode.turn_limit_reached", { + sessionId, + episodeId: prev.episodeId, + turns: prevSnap.turns.length, + maxTurnsPerEpisode: algorithm.session.maxTurnsPerEpisode, + relation: decision.relation, + source: "closed_episode", + }); + } else { + const reopenReason = + decision.relation === "revision" ? "revision" : "follow_up"; + const snap = session.sessionManager.reopenEpisode(prev.episodeId, reopenReason); + session.sessionManager.addTurn(prev.episodeId, { + role: "user", + content: userText, + ts: turnTs, + meta: { + source: reopenReason, + classifiedRelation: decision.relation, + ...meta, + }, + }); + openEpisodeBySession.set(sessionId, prev.episodeId); + lastEpisodeBySession.delete(sessionId); + return { episode: snap, sessionId, relation: decision.relation }; + } } if (decision.relation === "new_task") { diff --git a/apps/memos-local-plugin/core/pipeline/types.ts b/apps/memos-local-plugin/core/pipeline/types.ts index b1dceed23..830e6e577 100644 --- a/apps/memos-local-plugin/core/pipeline/types.ts +++ b/apps/memos-local-plugin/core/pipeline/types.ts @@ -115,6 +115,18 @@ export interface SessionRoutingConfig { * new-episode boundary even for revision/follow_up verdicts. */ mergeMaxGapMs: number; + /** + * Hard cap on turns in a merged episode. Once reached, the next turn + * forces a topic boundary before appending to keep topic-end work bounded. + */ + maxTurnsPerEpisode: number; + /** + * Max time to wait for relation classification before defaulting to + * a conservative new-task boundary. + */ + classifyTimeoutMs: number; + /** Shared LLM concurrency budget for async background subscribers. */ + bgLlmConcurrency: number; } // ─── Dependency graph ───────────────────────────────────────────────────── diff --git a/apps/memos-local-plugin/core/util/rate-limited-llm.ts b/apps/memos-local-plugin/core/util/rate-limited-llm.ts new file mode 100644 index 000000000..4d229b4c9 --- /dev/null +++ b/apps/memos-local-plugin/core/util/rate-limited-llm.ts @@ -0,0 +1,88 @@ +import type { + LlmCallOptions, + LlmClient, + LlmClientStats, + LlmCompleteJsonOptions, + LlmCompletion, + LlmJsonCompletion, + LlmMessage, + LlmProviderName, + LlmStreamChunk, +} from "../llm/types.js"; +import type { Semaphore } from "./semaphore.js"; + +/** + * Wrap an LLM client so expensive background subscribers share one + * process-wide concurrency budget without changing call-site semantics. + */ +export function rateLimitLlmClient(client: LlmClient | null, semaphore: Semaphore): LlmClient | null { + if (!client) return null; + return new RateLimitedLlmClient(client, semaphore); +} + +class RateLimitedLlmClient implements LlmClient { + constructor( + private readonly inner: LlmClient, + private readonly semaphore: Semaphore, + ) {} + + get provider(): LlmProviderName { + return this.inner.provider; + } + + get model(): string { + return this.inner.model; + } + + get canStream(): boolean { + return this.inner.canStream; + } + + async complete( + messages: LlmMessage[] | string, + opts?: LlmCallOptions, + ): Promise { + const release = await this.semaphore.acquire(); + try { + return await this.inner.complete(messages, opts); + } finally { + release(); + } + } + + async completeJson( + messages: LlmMessage[] | string, + opts?: LlmCompleteJsonOptions, + ): Promise> { + const release = await this.semaphore.acquire(); + try { + return await this.inner.completeJson(messages, opts); + } finally { + release(); + } + } + + async *stream( + messages: LlmMessage[] | string, + opts?: LlmCallOptions, + ): AsyncIterable { + const release = await this.semaphore.acquire(); + try { + yield* this.inner.stream(messages, opts); + } finally { + release(); + } + } + + stats(): LlmClientStats { + return this.inner.stats(); + } + + resetStats(): void { + this.inner.resetStats(); + } + + close(): Promise { + return this.inner.close(); + } +} diff --git a/apps/memos-local-plugin/core/util/semaphore.ts b/apps/memos-local-plugin/core/util/semaphore.ts new file mode 100644 index 000000000..8dd9b75fc --- /dev/null +++ b/apps/memos-local-plugin/core/util/semaphore.ts @@ -0,0 +1,30 @@ +export interface Semaphore { + acquire(): Promise<() => void>; +} + +export function createSemaphore(max: number): Semaphore { + const limit = Math.max(1, Math.floor(max)); + let current = 0; + const waiters: Array<() => void> = []; + + return { + async acquire() { + if (current < limit) { + current++; + return release; + } + return new Promise<() => void>((resolve) => { + waiters.push(() => { + current++; + resolve(release); + }); + }); + }, + }; + + function release() { + current = Math.max(0, current - 1); + const next = waiters.shift(); + if (next) next(); + } +} From e067cf8881b3fff29306d6a9737aff152f977476 Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:44:32 +0200 Subject: [PATCH 25/33] fix(logging): initialize logger from config in the standalone bridge (#1955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MemOS daemon (`bridge.cts`) resolved config inside `bootstrapMemoryCoreFull` but never called `initLogger`, so the active logger stayed on the `bootstrapConsoleOnly()` default with `tz` pinned to "UTC". This made `logging.timezone` (PR #44) inert in the daemon — and the rest of `logging.*` (level, channels, file/audit/llm/perf/events sinks) dead too. Pretty/compact timestamps kept printing UTC regardless of config. Add an opt-in `initLogging` flag to `BootstrapOptions`. When set, `bootstrapMemoryCoreFull` calls `initLogger(config, home)` right after config resolution, before anything logs. The standalone bridge passes it; embedded plugin hosts (`adapters/openclaw/index.ts`) leave it off so the host keeps control of its own logging. Test: bootstrap with `logging.timezone` set + `initLogging: true` stamps records with the configured tz; without the flag the logger is untouched. --- apps/memos-local-plugin/bridge.cts | 5 ++ .../core/pipeline/memory-core.ts | 16 +++- .../unit/pipeline/bootstrap-logging.test.ts | 87 +++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 apps/memos-local-plugin/tests/unit/pipeline/bootstrap-logging.test.ts diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index 26f1a1723..0eedc4106 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -288,6 +288,11 @@ async function main(): Promise { pkgVersion, hostLlmBridge: args.daemon ? null : lazyHostLlmBridge, home: resolvedHome, + // Standalone bridge owns its stdio — initialize the logger from + // config.logging (timezone, level, channels, file sinks). Without this the + // logger stays on the bootstrap console default (tz pinned to "UTC"), which + // makes logging.timezone inert in the daemon. + initLogging: true, }); const telemetry = new Telemetry( diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 39c6f3049..12d512b96 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -75,7 +75,7 @@ import type { import type { ResolvedConfig, ResolvedHome } from "../config/index.js"; import { loadConfig, resolveHome, SECRET_FIELD_PATHS } from "../config/index.js"; import { feedbackText, runFeedbackExperience } from "../experience/feedback-builder.js"; -import { rootLogger } from "../logger/index.js"; +import { initLogger, rootLogger } from "../logger/index.js"; import type { Logger } from "../logger/types.js"; import { openDb } from "../storage/connection.js"; import { runMigrations } from "../storage/migrator.js"; @@ -165,6 +165,13 @@ export interface BootstrapOptions { hostLlmBridge?: HostLlmBridge | null; /** Optional telemetry instance for ARMS RUM reporting. */ telemetry?: import("../telemetry/index.js").Telemetry | null; + /** + * When true, initialize the global logger from `config.logging` (timezone, + * level, channels, file/audit/llm/perf/events sinks). The standalone daemon + * (`bridge.cts`) owns its stdio and must set this; embedded plugin hosts + * leave it false so the host keeps control of logging. + */ + initLogging?: boolean; } export interface BootstrapResult { @@ -204,6 +211,13 @@ export async function bootstrapMemoryCoreFull( : await loadConfig(home); const config = configResult.config; + // Standalone daemon: wire the global logger from config (timezone, level, + // channels, file sinks) before anything logs. Embedded hosts skip this and + // keep their own logger. Idempotent — re-init swaps the active root in place. + if (options.initLogging) { + initLogger(config, home); + } + const log = rootLogger.child({ channel: "core.pipeline.bootstrap", ctx: { agent: options.agent }, diff --git a/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-logging.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-logging.test.ts new file mode 100644 index 000000000..89bdd2091 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-logging.test.ts @@ -0,0 +1,87 @@ +/** + * Bootstrap logger initialization. + * + * The standalone daemon (`bridge.cts`) resolves config inside + * `bootstrapMemoryCoreFull` but historically never called `initLogger`, so the + * active logger stayed on the `bootstrapConsoleOnly()` default with `tz` pinned + * to "UTC". That made `logging.timezone` (and the rest of the `logging.*` + * block) inert in the daemon. These tests pin the opt-in wiring. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { makeTmpHome, type TmpHomeContext } from "../../helpers/tmp-home.js"; +import { + initTestLogger, + memoryBuffer, + rootLogger, +} from "../../../core/logger/index.js"; +import type { MemoryCore } from "../../../agent-contract/memory-core.js"; + +describe("bootstrapMemoryCoreFull logger init", () => { + let home: TmpHomeContext | null = null; + let core: MemoryCore | null = null; + + afterEach(async () => { + if (core) await core.shutdown(); + if (home) await home.cleanup(); + core = null; + home = null; + initTestLogger(); + }); + + it("initializes the active logger from config when initLogging is set", async () => { + const { bootstrapMemoryCoreFull } = await import( + "../../../core/pipeline/memory-core.js" + ); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: ` +logging: + timezone: America/Los_Angeles +`, + }); + + const result = await bootstrapMemoryCoreFull({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "bootstrap-test", + initLogging: true, + }); + core = result.core; + + rootLogger.child({ channel: "core.session" }).info("bootstrap.tz"); + await rootLogger.flush(); + + expect(memoryBuffer().tail({ limit: 1 }).at(0)?.tz).toBe( + "America/Los_Angeles", + ); + }); + + it("leaves the logger untouched when initLogging is not requested", async () => { + const { bootstrapMemoryCoreFull } = await import( + "../../../core/pipeline/memory-core.js" + ); + home = await makeTmpHome({ + agent: "openclaw", + configYaml: ` +logging: + timezone: America/Los_Angeles +`, + }); + + const result = await bootstrapMemoryCoreFull({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "bootstrap-test", + }); + core = result.core; + + rootLogger.child({ channel: "core.session" }).info("bootstrap.default"); + await rootLogger.flush(); + + // Embedded-plugin path: host owns logging, so the default UTC logger stays. + expect(memoryBuffer().tail({ limit: 1 }).at(0)?.tz).toBe("UTC"); + }); +}); From 653cee4fd6904bcd6beb05405413e29a38f07c6b Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:45:52 +0200 Subject: [PATCH 26/33] fix(bridge): add 20s timeout guard to core.shutdown() to prevent orphaned processes (#1799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bridge): add shutdown timeout to prevent orphan bridge processes core.shutdown() drains the L2/L3/skill flush pipeline which can block on hanging LLM calls. Without a deadline the bridge never exits after stdin EOF when the Python parent is already gone, re-creating the process-leak condition. Race all three shutdown sites (daemon SIGTERM, non-daemon SIGTERM, headless stdin-EOF) against a 20s timeout so the process always terminates within a bounded time. Co-Authored-By: Claude Sonnet 4.6 * fix(bridge): wrap remaining core.shutdown() calls with 20s timeout Three sites missed by the original patch (56ebe7a3) were calling core.shutdown() without withShutdownTimeout, leaving the bridge process able to hang indefinitely if L2/L3/skill LLM calls stalled at shutdown: • bridge.cts: EADDRINUSE exit (×2) — daemon can't bind viewer port • bridge.cts: viewer-running keepalive path — stdin closes but viewer is still serving; core.shutdown fires from the interval callback All six core.shutdown() call sites now go through withShutdownTimeout, guaranteeing the bridge exits within 20s regardless of which path is taken. Adds bridge-shutdown-audit.md documenting the full call chain and confirming no blocking sync calls prevent the timeout from firing. Co-Authored-By: Claude Sonnet 4.6 * fix(docs): correct stale line references + move audit doc to apps/memos-local-plugin/docs/ - Removed companion-stable branch reference from header - Updated bridge.cts line numbers to match main (per shinetata review) - Removed companion-stable commit hash 56ebe7a3 - Moved from repo root to apps/memos-local-plugin/docs/ per review request --------- Co-authored-by: Fayenix Co-authored-by: Claude Sonnet 4.6 --- apps/memos-local-plugin/bridge.cts | 21 +- .../docs/bridge-shutdown-audit.md | 180 ++++++++++++++++++ 2 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 apps/memos-local-plugin/docs/bridge-shutdown-audit.md diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index 0eedc4106..e26203836 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -36,6 +36,15 @@ const url = require("node:url") as typeof import("node:url"); const BRIDGE_STATUS_HEARTBEAT_MS = 5_000; const BRIDGE_STATUS_STALE_MS = 20_000; const BRIDGE_STATUS_FILE = "bridge-status.json"; +// If core.shutdown() or waitForShutdown() blocks (e.g. L2/L3 LLM calls +// hanging during flush), the bridge process would never exit after stdin +// EOF or SIGTERM. Race against this deadline so the process always exits +// within a bounded time even when the parent is already gone. +const SHUTDOWN_TIMEOUT_MS = 20_000; + +function withShutdownTimeout(p: Promise): Promise { + return Promise.race([p, new Promise((r) => setTimeout(r, SHUTDOWN_TIMEOUT_MS))]); +} interface BridgeArgs { daemon: boolean; @@ -462,13 +471,13 @@ async function main(): Promise { process.stderr.write( `bridge: daemon port :${viewerPort} still in use after ${maxBindAttempts}s — exiting.\n`, ); - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(1); } process.stderr.write( `bridge: daemon viewer failed: ${(err as Error)?.message ?? String(err)}\n`, ); - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(1); } } @@ -477,7 +486,7 @@ async function main(): Promise { process.stderr.write(`bridge: daemon received ${sig}, shutting down\n`); removeOwnedPidFile(); try { await viewer!.close(); } catch { /* best-effort */ } - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(0); }; process.on("SIGINT", () => void shutdownDaemon("SIGINT")); @@ -548,7 +557,7 @@ async function main(): Promise { /* best-effort */ } } - await waitForShutdown(core, activeStdio); + await withShutdownTimeout(waitForShutdown(core, activeStdio)); process.exit(0); }; @@ -569,7 +578,7 @@ async function main(): Promise { if (viewer!.closed) { clearInterval(keepalive); removeOwnedPidFile(); - void core.shutdown().then(() => process.exit(0)); + void withShutdownTimeout(core.shutdown()).then(() => process.exit(0)); } }, 5_000); (keepalive as unknown as { unref?: () => void }).unref?.(); @@ -578,7 +587,7 @@ async function main(): Promise { // No viewer (headless bridge) — clean exit. removeOwnedPidFile(); - await core.shutdown(); + await withShutdownTimeout(core.shutdown()); process.exit(0); } diff --git a/apps/memos-local-plugin/docs/bridge-shutdown-audit.md b/apps/memos-local-plugin/docs/bridge-shutdown-audit.md new file mode 100644 index 000000000..a933d4898 --- /dev/null +++ b/apps/memos-local-plugin/docs/bridge-shutdown-audit.md @@ -0,0 +1,180 @@ +# Bridge Shutdown Path Audit + +**Date:** 2026-05-24 +**Purpose:** Verify the 20s `withShutdownTimeout` patch covers every path where +`core.shutdown()` is called and a hang could orphan the bridge process. + +--- + +## Shutdown call sites + +### 1. Daemon SIGTERM / SIGINT — `bridge.cts:471` + +```ts +const shutdownDaemon = async (sig: string) => { + removeOwnedPidFile(); + try { await viewer!.close(); } catch { /* best-effort */ } + await withShutdownTimeout(core.shutdown()); // ✅ COVERED + process.exit(0); +}; +process.on("SIGINT", () => void shutdownDaemon("SIGINT")); +process.on("SIGTERM", () => void shutdownDaemon("SIGTERM")); +``` + +**Verdict:** covered by the existing patch. + +--- + +### 2. Non-daemon SIGTERM / SIGINT — `bridge.cts:546` + +```ts +const shutdown = async (sig: string) => { + removeOwnedPidFile(); + if (viewer) { try { await viewer.close(); } catch {} } + await withShutdownTimeout(waitForShutdown(core, activeStdio)); // ✅ COVERED + process.exit(0); +}; +``` + +`waitForShutdown` calls `core.shutdown()` internally; the race wraps the whole chain. +**Verdict:** covered. + +--- + +### 3. Headless stdin-EOF exit — `bridge.cts:574` + +```ts +removeOwnedPidFile(); +await withShutdownTimeout(core.shutdown()); // ✅ COVERED +process.exit(0); +``` + +**Verdict:** covered. + +--- + +### 4. Viewer-running keepalive path — `bridge.cts:563` ⚠️ PREVIOUSLY UNCOVERED + +Reached when stdin closes but the HTTP viewer is still serving. +Before this patch: + +```ts +void core.shutdown().then(() => process.exit(0)); // ❌ no timeout +``` + +The `setInterval` keepalive is `.unref()`'d, but `core.shutdown()` creates active +LLM-request HTTP connections that ARE ref'd — so the process stays alive until +shutdown completes. If `flush()` hangs on an L2/L3 LLM call this path orphaned +the bridge permanently. + +**Fix** (this PR): wrap with `withShutdownTimeout`. + +```ts +void withShutdownTimeout(core.shutdown()).then(() => process.exit(0)); // ✅ FIXED +``` + +--- + +### 5. Daemon EADDRINUSE / viewer-error exit — `bridge.cts:456` ⚠️ PREVIOUSLY UNCOVERED + +Two sequential error paths when the daemon can't bind the viewer port: + +```ts +// Path A: port still busy after 10 retry attempts +await core.shutdown(); // ❌ no timeout +process.exit(1); + +// Path B: non-EADDRINUSE error starting viewer +await core.shutdown(); // ❌ no timeout +process.exit(1); +``` + +If `core.shutdown()` blocks here (L2/L3 mid-flight, network hang), the daemon +stays alive indefinitely with no viewer and no JSON-RPC pipe — invisible but +burning CPU. + +**Fix** (this PR): both calls wrapped with `withShutdownTimeout`. + +--- + +## `core.shutdown()` internal chain + +``` +core.shutdown() [memory-core.ts:1442] + hubRuntime?.stop() — clears timers, closes http.Server (fast) + handle.shutdown() [orchestrator.ts:1357] + flush() + capture.drain() — SQLite writes (sync, fast) + reward.drain() — LLM scoring call (async, ~2-10s) + l2.drain() — L2 induction LLM call (async, ~5s) + l3.drain() — L3 abstraction LLM call (async) + skills.flush() — skill crystallization LLM call (async) + feedback.flush() — async + embeddingRetryWorker.flush()— ONNX inference (async JS, event loop stays live) + detach subscribers + sessionManager.shutdown() + telemetry.shutdown() + db.close() — better-sqlite3 close (sync, fast) +``` + +**Key invariant:** every potentially slow operation in `flush()` is async and +yields the event loop. The `setTimeout` inside `withShutdownTimeout` can always +fire because Node's event loop is not blocked by any of these calls. Once the +race resolves (either flush completes or timeout fires), `process.exit()` runs +immediately and terminates the process regardless of any in-flight HTTP or ONNX +work. + +--- + +## Blocking call analysis + +| Call | Sync/async | Can block event loop? | Covered by timeout? | +|---|---|---|---| +| `better-sqlite3` writes | Sync C++ | Yes, briefly (<1ms per stmt) | N/A — too fast | +| `better-sqlite3` WAL checkpoint | Sync C++ | Yes, briefly | N/A — auto, fast | +| LLM HTTP calls (L2/L3/reward/skill) | Async | No | ✅ via Promise.race | +| ONNX embedding (`@hf/transformers`) | Async WASM | No | ✅ via Promise.race | +| ONNX model download (first-time) | Async fetch | No | ✅ via Promise.race | +| `hubRuntime.stop()` → `http.Server.close()` | Async | No | ✅ wrapped by outer race | + +**Conclusion:** no synchronous call in the shutdown chain is long enough to +prevent the `setTimeout` from firing. The 20s timeout is effective across all +paths after this patch. + +--- + +## Remaining considerations (not bugs — lower priority) + +### Daemon respawn loop + +`killExistingBridge()` reads the PID file, sends SIGTERM, waits up to 5s, then +SIGKILL. If the new bridge crashes before writing its PID file, the next bridge +still starts cleanly (stale PID in file → `process.kill(pid, 0)` throws → +`readPidFile` returns null → no double-kill). No orphan accumulation path found. + +### Python `ensure_viewer_daemon()` kill + +When the Python health probe kills a bridge that didn't bind port 18800 within +15s, `killExistingBridge()` in the replacement bridge handles teardown. The +replacement bridge sends SIGTERM to the old one, waits 5s, then SIGKILL. The +old bridge's SIGTERM handler calls `shutdownDaemon` which is covered by +`withShutdownTimeout`. If the 5s wait expires before the SIGTERM handler +finishes, Python SIGKILL's the old bridge — force-kill is always safe. + +### Two-bridge SQLite lock race + +`PRAGMA busy_timeout = 5000` is set at DB open. If two bridges compete for a +write lock, the loser waits up to 5s then receives `SQLITE_BUSY`. This surfaces +as a caught error in the repo layer, not a hang. + +--- + +## Summary + +| Site | Before patch | After patch | +|---|---|---| +| Daemon SIGTERM | ✅ covered | ✅ covered | +| Non-daemon SIGTERM | ✅ covered | ✅ covered | +| Headless stdin-EOF | ✅ covered | ✅ covered | +| Viewer-running keepalive | ❌ no timeout | ✅ FIXED | +| EADDRINUSE/viewer-error exit (×2) | ❌ no timeout | ✅ FIXED | From 9c40357fc37e48b3fa03ce1f8f3badf89f8a51dc Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:50:07 +0200 Subject: [PATCH 27/33] fix(build): rebuild bridge.cjs when any TypeScript source is newer (#1850) * fix(migrations): remove duplicate ALTER TABLE stmts from 008 now in 001 All columns added by 008-feedback-experience-metadata were backported into 001-initial.sql; the ALTER TABLE statements now fail on fresh DBs. Keeps only the idempotent CREATE INDEX lines. Also fix scripts/replay.py bridge_client import path (adapters/ is under apps/memos-local-plugin/, not the repo root) and add scripts/export-state-sessions.py for exporting state.db sessions to JSON files compatible with replay.py. Co-Authored-By: Claude Sonnet 4.6 * fix(daemon): rebuild when any .ts source is newer than dist/bridge.cjs _rebuild_if_stale() only checked bridge.cts mtime, so changes to core/**/*.ts (e.g. memory-core.ts) didn't trigger an auto-rebuild on gateway restart. Walk all .ts files under plugin_root and compare the newest mtime against the compiled binary instead. --------- Co-authored-by: Claude Sonnet 4.6 --- .../hermes/memos_provider/daemon_manager.py | 49 +++++++++++++++++++ .../008-feedback-experience-metadata.sql | 17 +------ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py index 555fffc8c..98d242731 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py @@ -167,6 +167,55 @@ def _bridge_command(*, daemon: bool) -> list[str]: return [node, "--import", "tsx", *bridge_args] +def _rebuild_if_stale() -> bool: + """Run `npm run build` if any TypeScript source is newer than dist/bridge.cjs. + + Returns True if the binary is current or the build succeeded. + Returns False if the build failed — caller should continue with + the existing binary rather than blocking gateway startup. + """ + plugin_root = _plugin_root() + compiled = plugin_root / "dist" / "bridge.cjs" + + if compiled.exists(): + compiled_mtime = compiled.stat().st_mtime + newest_source = max( + (p.stat().st_mtime for p in plugin_root.rglob("*.ts")), + default=0.0, + ) + if newest_source <= compiled_mtime: + return True + + logger.info("MemOS: TypeScript source newer than dist/bridge.cjs — rebuilding...") + npm = shutil.which("npm") + if not npm: + logger.warning("MemOS: npm not found on PATH; skipping bridge rebuild") + return False + try: + result = subprocess.run( + [npm, "run", "build"], + cwd=str(plugin_root), + check=True, + timeout=120, + capture_output=True, + text=True, + ) + logger.info("MemOS: bridge daemon rebuilt successfully") + if result.stderr: + logger.debug("MemOS: build stderr: %s", result.stderr.strip()) + return True + except subprocess.CalledProcessError as e: + logger.error("MemOS: bridge rebuild failed:\n%s", e.stderr) + return False + except subprocess.TimeoutExpired: + logger.error("MemOS: bridge rebuild timed out after 120s") + return False + except Exception as e: + logger.error("MemOS: bridge rebuild error: %s", e) + return False + + + def ensure_bridge_running(*, probe_only: bool = False) -> bool: """Return True when the bridge is (or can be) operational. diff --git a/apps/memos-local-plugin/core/storage/migrations/008-feedback-experience-metadata.sql b/apps/memos-local-plugin/core/storage/migrations/008-feedback-experience-metadata.sql index a25ef2ae7..bdf5f385a 100644 --- a/apps/memos-local-plugin/core/storage/migrations/008-feedback-experience-metadata.sql +++ b/apps/memos-local-plugin/core/storage/migrations/008-feedback-experience-metadata.sql @@ -1,20 +1,5 @@ -- Add explicit metadata for feedback-derived experiences. --- Existing policies are treated as success-backed procedural experience so --- skill crystallization keeps its legacy behavior for old rows. -ALTER TABLE policies ADD COLUMN experience_type TEXT NOT NULL DEFAULT 'success_pattern' - CHECK (experience_type IN ('success_pattern','repair_validated','failure_avoidance','repair_instruction','preference','verifier_feedback','procedural')); -ALTER TABLE policies ADD COLUMN evidence_polarity TEXT NOT NULL DEFAULT 'positive' - CHECK (evidence_polarity IN ('positive','negative','neutral','mixed')); -ALTER TABLE policies ADD COLUMN salience REAL NOT NULL DEFAULT 0; -ALTER TABLE policies ADD COLUMN confidence REAL NOT NULL DEFAULT 0.5; -ALTER TABLE policies ADD COLUMN source_feedback_ids_json TEXT NOT NULL DEFAULT '[]' - CHECK (json_valid(source_feedback_ids_json)); -ALTER TABLE policies ADD COLUMN source_trace_ids_json TEXT NOT NULL DEFAULT '[]' - CHECK (json_valid(source_trace_ids_json)); -ALTER TABLE policies ADD COLUMN verifier_meta_json TEXT NOT NULL DEFAULT 'null' - CHECK (json_valid(verifier_meta_json)); -ALTER TABLE policies ADD COLUMN skill_eligible INTEGER NOT NULL DEFAULT 1 - CHECK (skill_eligible IN (0,1)); +-- All ALTER TABLE columns backported into 001-initial.sql; only indexes remain here. CREATE INDEX IF NOT EXISTS idx_policies_experience ON policies(experience_type, evidence_polarity, updated_at DESC); From 63ddb97e494544e6314b2c005ef7eb91532504d8 Mon Sep 17 00:00:00 2001 From: Erick Wipprecht <53452309+chiefmojo@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:53:42 +0200 Subject: [PATCH 28/33] fix: remove 500-row cap that truncated viewer count displays (#1954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove 500-row cap that truncated viewer count displays clampLimit() in _helpers.ts capped all repo list() calls at 500, silently truncating skill/policy/world-model counts everywhere they were derived from array length rather than a COUNT(*) query. - Raise clampLimit cap 500 → 100,000 so metrics() and other internal analytics can read full datasets (fixes Analytics tab) - Rewrite /api/v1/overview to use countSkills/countPolicies/ countWorldModels/countEpisodes instead of list+length, making Overview counts correct regardless of scale (fixes Overview tab) - Align countSkills to use limit:100_000 consistent with other count methods * feat: chunk batch reflection scoring (#1957) * Fix #1540: fix: (#1824) docs(memos-local-plugin): clarify install path and stale dir names (#1540) The README's 'Quick start' section told users to use install.sh instead of npm install, but the warning was buried and users still tried 'npm install -g @memtensor/memos-local-plugin' first. The reporter in #1540 encountered this on a Hermes deployment. This change: - Promotes the 'do not run npm install -g' notice to a prominent IMPORTANT callout explaining why global install is wrong (no agent-home deploy, no config.yaml, no bridge/viewer) and that the tarball intentionally ships built artifacts only. - Adds a Troubleshooting subsection covering the two specific symptoms in the bug report: the 'package not found' misread, and the stale web/ and site/ directory names (web/ is now viewer/, site/ was removed by commit 26e7e3db). - Mentions install.ps1 for Windows alongside install.sh. - CHANGELOG: record the docs fix and reference #1540. Documentation-only change; no code or runtime behavior touched. Co-authored-by: MemOS AutoDev Co-authored-by: Matthew * Fix #1888: [Bug] test_system_parser.py: SystemParser.__init__() got an unexpected keyword a (#1889) fix: remove invalid chunker parameter from SystemParser test instantiation - SystemParser.__init__() signature changed to (embedder, llm=None) - Test was still passing chunker=None causing TypeError - Fixes all 5 failing tests in test_system_parser.py Fixes #1888 Co-authored-by: MemOS AutoDev Co-authored-by: Matthew * Fix #1525: [Bug] clean_json_response crashes with cryptic AttributeError when given None (#1884) * test: add comprehensive tests for clean_json_response (issue #1525) - Add test suite in tests/mem_os/test_format_utils.py - Cover None input ValueError with diagnostic message - Cover markdown removal, whitespace stripping, edge cases - Verify fix for AttributeError when LLM returns None * style: format clean_json_response tests --------- Co-authored-by: MemOS AutoDev Co-authored-by: Matthew * Fix #1901: share_cube_with_user passes swapped args to _validate_cube_access — fails for ev (#1903) fix: validate current user not target in share_cube_with_user (#1901) share_cube_with_user(cube_id, target_user_id) called _validate_cube_access(cube_id, target_user_id), but the validator signature is (user_id, cube_id). The cube_id therefore landed in the user_id slot and _validate_user_exists raised "User '' does not exist or is inactive" for every well-formed call, making the API unusable. The in-code comment "Validate current user has access to this cube" already documented the correct intent: the sharing user (self.user_id) must have access to the cube being shared, not the target. Switch the call to self._validate_cube_access(self.user_id, cube_id). The target user's existence is independently checked on the next line via validate_user(target_user_id), so that path is unchanged. Add regression tests in tests/mem_os/test_memos_core.py that pin down: - validate_user_cube_access is consulted with (self.user_id, cube_id), - add_user_to_cube is called with (target_user_id, cube_id) on success, - a missing target raises "Target user '' does not exist". Closes #1901 Co-authored-by: MemOS AutoDev Bot Co-authored-by: Matthew * feat(memos): chunk batch reflection scoring * Fix #1897: bug(memos-local-plugin): Hermes background model status checks can trigger paid (#1899) * Fix #1897: fix(memos-local-plugin): add LLM circuit breaker for terminal provider errors Issue #1897 reported ~12,900 paid LLM requests in 24 h on Hermes against a DeepSeek key with insufficient balance. The local `system_model_status` row count (12,900) closely tracked the provider-side `request_count` (11,344) for the same billing window. The naming is misleading: `system_model_status` is not a health probe; it is the audit row written once per LLM call (ok / fallback / error) inside `core/llm/client.ts`. With no circuit breaker, every pipeline subscriber (capture / session-relation / reward / L2 / L3 / skill / retrieval LLM filter / world-model) kept firing on every turn / closed episode / induction, generating one paid request each. Add a per-`LlmClient` circuit breaker: - Trips on terminal errors: HTTP 401/402/403 or messages containing `insufficient balance` / `invalid api key` / `unauthorized` / `account suspended` / `billing`. - Open: short-circuits subsequent calls inside the facade without contacting the provider. Throws `MemosError(LLM_UNAVAILABLE)` with `details.circuitOpen=true` so existing catch blocks still work. - Half-open after cool-down (default 5 min, configurable, min 30 s): next call probes the provider; success closes the breaker, terminal failure re-opens it for another cool-down. - Host fallback rescues a call without tripping the breaker — fallback exists precisely to keep going when the primary is down. - Coalesces `system_model_status="circuit_open"` audit rows to at most one per ~25 s while the breaker stays open, so we don't replace paid spam with audit-row spam. - Exposes `circuitOpen` / `circuitOpenUntil` / `circuitOpenedReason` via `LlmClientStats` for the Overview viewer card. - Enabled by default; legacy behaviour available via `circuitBreaker.enabled = false`. Tests: 9 new vitest cases under `tests/unit/llm/client.test.ts` covering trip on 402, trip on "insufficient balance" message, no trip on generic transient, coalescing, half-open close on success, host-fallback rescues without trip, disabled mode, stats fields, and re-open on terminal probe failure. All 59 LLM and 28 pipeline tests pass; `tsc --noEmit` clean. Out of scope (tracked separately): 429 `Retry-After` handling (issue #1620), per-tool rate limits, daily budget caps. * Fix LLM breaker with host fallback --------- Co-authored-by: autodev-bot Co-authored-by: Jiang <33757498+hijzy@users.noreply.github.com> Co-authored-by: GU TIANCHUN <96930846+TianchunGu@users.noreply.github.com> Co-authored-by: Dubberman <48425266+whipser030@users.noreply.github.com> --------- Co-authored-by: Memtensor-AI Co-authored-by: MemOS AutoDev Co-authored-by: Matthew Co-authored-by: MemOS AutoDev Co-authored-by: MemOS AutoDev Bot Co-authored-by: Jiang <33757498+hijzy@users.noreply.github.com> Co-authored-by: GU TIANCHUN <96930846+TianchunGu@users.noreply.github.com> Co-authored-by: Dubberman <48425266+whipser030@users.noreply.github.com> * Revert "feat: chunk batch reflection scoring (#1957)" (#2145) This reverts commit 21a27ed19dcbbfcf830a3ce1253aed5b2ee66898. Co-authored-by: jiachengzhen * test: stub overview count methods --------- Co-authored-by: jiachengzhen Co-authored-by: Memtensor-AI Co-authored-by: MemOS AutoDev Co-authored-by: Matthew Co-authored-by: MemOS AutoDev Co-authored-by: MemOS AutoDev Bot Co-authored-by: Jiang <33757498+hijzy@users.noreply.github.com> Co-authored-by: GU TIANCHUN <96930846+TianchunGu@users.noreply.github.com> Co-authored-by: Dubberman <48425266+whipser030@users.noreply.github.com> Co-authored-by: zhaxi --- .../core/pipeline/memory-core.ts | 2 +- .../server/routes/overview.ts | 77 +++++++++++++------ .../tests/unit/server/http.test.ts | 2 + 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 12d512b96..bc0c7e689 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -3764,7 +3764,7 @@ export function createMemoryCore( includeAllNamespaces?: boolean; }): Promise { ensureLive(); - return handle.repos.skills.list({ status: input?.status, limit: 5_000 }).filter((r) => + return handle.repos.skills.list({ status: input?.status, limit: 100_000 }).filter((r) => (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) ).length; } diff --git a/apps/memos-local-plugin/server/routes/overview.ts b/apps/memos-local-plugin/server/routes/overview.ts index df3085da1..1d57c1252 100644 --- a/apps/memos-local-plugin/server/routes/overview.ts +++ b/apps/memos-local-plugin/server/routes/overview.ts @@ -33,42 +33,71 @@ export function registerOverviewRoutes(routes: Routes, deps: ServerDeps): void { // rewrites its active namespace on every turn/session, so scoped // reads here made the dashboard "drift to zero" as soon as a // message arrived (#2131). Same convention as diag.ts / session.ts. - const [health, episodeIds, skills, policies, worldModels, metrics] = - await Promise.all([ - deps.core.health(), - deps.core.listEpisodes({ limit: 5_000, includeAllNamespaces: true }), - deps.core.listSkills({ limit: 500, includeAllNamespaces: true }), - // Core only exposes `listPolicies({ status? })`; the viewer wants - // the grand total + per-status so we request the biggest page and - // break it down here. 500 is plenty — fresh installs have dozens. - deps.core.listPolicies({ limit: 500, includeAllNamespaces: true }), - deps.core.listWorldModels({ limit: 500, includeAllNamespaces: true }), - // `metrics.total` is the grand total of traces — cheaper than a - // dedicated count RPC and already cached by the core. - deps.core.metrics({ days: 1, includeAllNamespaces: true }), - ]); + const [ + health, + episodeCount, + skillActive, + skillCandidate, + skillArchived, + policyActive, + policyCandidate, + policyArchived, + worldModelCount, + metrics, + ] = await Promise.all([ + deps.core.health(), + deps.core.countEpisodes({ includeAllNamespaces: true }), + deps.core.countSkills({ + status: "active", + includeAllNamespaces: true, + }), + deps.core.countSkills({ + status: "candidate", + includeAllNamespaces: true, + }), + deps.core.countSkills({ + status: "archived", + includeAllNamespaces: true, + }), + deps.core.countPolicies({ + status: "active", + includeAllNamespaces: true, + }), + deps.core.countPolicies({ + status: "candidate", + includeAllNamespaces: true, + }), + deps.core.countPolicies({ + status: "archived", + includeAllNamespaces: true, + }), + deps.core.countWorldModels({ includeAllNamespaces: true }), + // `metrics.total` is the grand total of traces — cheaper than a + // dedicated count RPC and already cached by the core. + deps.core.metrics({ days: 1, includeAllNamespaces: true }), + ]); const skillStats = { - total: skills.length, - active: skills.filter((s) => s.status === "active").length, - candidate: skills.filter((s) => s.status === "candidate").length, - archived: skills.filter((s) => s.status === "archived").length, + total: skillActive + skillCandidate + skillArchived, + active: skillActive, + candidate: skillCandidate, + archived: skillArchived, }; const policyStats = { - total: policies.length, - active: policies.filter((p) => p.status === "active").length, - candidate: policies.filter((p) => p.status === "candidate").length, - archived: policies.filter((p) => p.status === "archived").length, + total: policyActive + policyCandidate + policyArchived, + active: policyActive, + candidate: policyCandidate, + archived: policyArchived, }; return { ok: health.ok, version: health.version, - episodes: episodeIds.length, + episodes: episodeCount, traces: metrics.total, skills: skillStats, policies: policyStats, - worldModels: worldModels.length, + worldModels: worldModelCount, llm: health.llm, embedder: health.embedder, skillEvolver: health.skillEvolver, diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index c3ba213e5..613fcb30e 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -65,6 +65,7 @@ function stubCore(): MemoryCore { shareTrace: vi.fn(async (id) => ({ id, share: { scope: "public" } } as any)), getPolicy: vi.fn(async (id) => ({ id, title: "p", status: "active" } as any)), listPolicies: vi.fn(async () => []), + countPolicies: vi.fn(async () => 0), setPolicyStatus: vi.fn(async (id, status) => ({ id, status } as any)), deletePolicy: vi.fn(async () => ({ deleted: false })), sharePolicy: vi.fn(async (id, share) => ({ id, share } as any)), @@ -72,6 +73,7 @@ function stubCore(): MemoryCore { editPolicyGuidance: vi.fn(async (id) => ({ id } as any)), getWorldModel: vi.fn(async () => null), listWorldModels: vi.fn(async () => []), + countWorldModels: vi.fn(async () => 0), deleteWorldModel: vi.fn(async () => ({ deleted: false })), shareWorldModel: vi.fn(async (id, share) => ({ id, status: "active", share } as any)), updateWorldModel: vi.fn(async (id, patch) => ({ id, status: "active", ...patch } as any)), From 1b0313f635478e5c833cd8b94d6daac2f91deaa8 Mon Sep 17 00:00:00 2001 From: Richard-JC-Yan <128337844+Richard-JC-Yan@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:04:20 +0800 Subject: [PATCH 29/33] fix(memos-local-openclaw): declare contracts.tools in plugin manifest (#1757) OpenClaw runtime requires every plugin to declare contracts.tools before registering agent tools. Without it, the gateway logs the following warning on every registration: [plugins] plugin must declare contracts.tools before registering agent tools (plugin=memos-local-openclaw-plugin, source=index.js) In a production OpenClaw deployment with active memos plugin, this warning flooded gateway.err.log at ~1300 lines/day, masking real errors and bloating logs by ~33MB/day. This commit declares all 20 tools the plugin actually registers (grep'd via `api.registerTool(...)` calls in index.ts): - memory_*: get, search, share, timeline, unshare, viewer, write_public - network_*: memory_detail, skill_pull, team_info - skill_*: file_get, files, get, install, publish, search, unpublish - task_*: share, summary, unshare Verified: post-deploy gateway.err.log warning count = 0. Refs: contracts.tools field per OpenClaw plugin spec (see openclaw-lark and similar plugins as reference). Co-authored-by: Richard-JC-Yan <150349207+Richard-JC-Yan@users.noreply.github.com> Co-authored-by: jiachengzhen --- .../memos-local-openclaw/openclaw.plugin.json | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/memos-local-openclaw/openclaw.plugin.json b/apps/memos-local-openclaw/openclaw.plugin.json index 3d370ef0e..9936e5d92 100644 --- a/apps/memos-local-openclaw/openclaw.plugin.json +++ b/apps/memos-local-openclaw/openclaw.plugin.json @@ -38,20 +38,26 @@ ], "contracts": { "tools": [ - "memory_search", "memory_get", + "memory_search", + "memory_share", "memory_timeline", + "memory_unshare", "memory_viewer", - "task_summary", - "skill_search", + "memory_write_public", + "network_memory_detail", + "network_skill_pull", + "network_team_info", + "skill_file_get", + "skill_files", + "skill_get", "skill_install", - "skill_upgrade", - "memory_share", - "memory_unshare", "skill_publish", + "skill_search", "skill_unpublish", - "network_skill_pull", - "network_team_info" + "task_share", + "task_summary", + "task_unshare" ] } } From 44fc2b54d7fbb4c4af7968ccd85d8cd77303dff2 Mon Sep 17 00:00:00 2001 From: Memtensor-AI Date: Thu, 23 Jul 2026 19:17:07 +0800 Subject: [PATCH 30/33] =?UTF-8?q?Fix=20#2148:=20[Bug]=20captureRunner=20us?= =?UTF-8?q?es=20reflectLlm=20(skill-evolver)=20for=20batch=20reflection=20?= =?UTF-8?q?=E2=80=94=20shoul=20(#2151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugin): wire capture reflection to main llm (#2148) Capture batch reflection is a JSON-output task. When operators configure skillEvolver.enableThinking=true alongside llm.enableThinking=false (the typical Qwen3 setup), passing `deps.reflectLlm` into the capture runner made the batch reflection run on the thinking-enabled skill-evolver model, which emits blocks that break JSON parsing and produce `malformed JSON` errors during capture summarisation. Route the capture runner's reflectLlm slot to `deps.llm` so JSON tasks stay on the non-thinking main model, matching the l3Llm pattern from #1959. - deps.ts: captureRunner.reflectLlm = deps.llm - tests: regression guard in tests/unit/pipeline/capture-reflect-llm-wiring.test.ts Co-Authored-By: Claude Opus 4.7 (1M context) * docs(plugin): clarify reflectLlm exposure comment (#2148 OCR follow-up) Enumerate the two read-only consumers that still reach `deps.reflectLlm` (reward-runner evaluator metadata + Overview health card via `resolveSkillEvolver`) and note that no other subscriber is wired to it. Prevents future readers from misreading the previous prose as "skill / capture also use reflectLlm". --------- Co-authored-by: autodev-bot Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: MemTensor Bot Co-authored-by: jiachengzhen --- apps/memos-local-plugin/core/pipeline/deps.ts | 8 +- .../capture-reflect-llm-wiring.test.ts | 192 ++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 apps/memos-local-plugin/tests/unit/pipeline/capture-reflect-llm-wiring.test.ts diff --git a/apps/memos-local-plugin/core/pipeline/deps.ts b/apps/memos-local-plugin/core/pipeline/deps.ts index c4d3dd413..8a0772119 100644 --- a/apps/memos-local-plugin/core/pipeline/deps.ts +++ b/apps/memos-local-plugin/core/pipeline/deps.ts @@ -222,7 +222,13 @@ export function buildPipelineSubscribers( episodesRepo: adaptEpisodesRepo(deps.repos.episodes), embedder: deps.embedder, llm: bgLlm, - reflectLlm: bgReflectLlm, + // Issue #2148: capture batch reflection emits JSON, so it must use + // the main model rather than the potentially thinking-enabled + // skill-evolver model. Keep the background wrapper so capture also + // participates in the shared concurrency limit. `bgReflectLlm` + // remains read-only evaluator metadata below; the original + // `deps.reflectLlm` is also exposed to the Overview health card. + reflectLlm: bgLlm, bus: buses.capture, cfg: algorithm.capture, now: deps.now, diff --git a/apps/memos-local-plugin/tests/unit/pipeline/capture-reflect-llm-wiring.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/capture-reflect-llm-wiring.test.ts new file mode 100644 index 000000000..3eede9e1c --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/capture-reflect-llm-wiring.test.ts @@ -0,0 +1,192 @@ +/** + * Regression test for issue #2148 — + * `captureRunner` must receive the main `llm` for its batch-reflection + * pass, NOT `reflectLlm` (skill-evolver). + * + * Background: batch reflection is a JSON-output task. When the operator + * configures a stronger, thinking-enabled model under `skillEvolver.*` + * and leaves `llm.enableThinking=false`, wiring `reflectLlm` (skill- + * evolver) into the capture pipeline makes reflection produce + * `...` blocks that break JSON parsing. The skill-evolver + * model is intended for skill crystallization, not capture reflection. + * + * This test locks the wiring at the pipeline layer: no matter what the + * caller sets on `deps.reflectLlm`, `buildPipelineSubscribers` must pass + * the same rate-limited main-LLM client to both capture-runner slots. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + LlmCallOptions, + LlmClient, + LlmClientStats, + LlmMessage, + LlmProviderName, +} from "../../../core/llm/types.js"; + +const captureRunnerCalls: Array<{ + llm: LlmClient | null; + reflectLlm: LlmClient | null; +}> = []; + +vi.mock("../../../core/capture/index.js", async () => { + const actual = await vi.importActual< + typeof import("../../../core/capture/index.js") + >("../../../core/capture/index.js"); + return { + ...actual, + createCaptureRunner: (deps: { + llm: LlmClient | null; + reflectLlm: LlmClient | null; + [k: string]: unknown; + }) => { + captureRunnerCalls.push({ llm: deps.llm, reflectLlm: deps.reflectLlm }); + return actual.createCaptureRunner( + deps as Parameters[0], + ); + }, + }; +}); + +import { + buildPipelineBuses, + buildPipelineSession, + buildPipelineSubscribers, + extractAlgorithmConfig, + type PipelineDeps, +} from "../../../core/pipeline/index.js"; +import { DEFAULT_CONFIG } from "../../../core/config/defaults.js"; +import { resolveHome } from "../../../core/config/paths.js"; +import { rootLogger } from "../../../core/logger/index.js"; +import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; +import { fakeEmbedder } from "../../helpers/fake-embedder.js"; + +function fakeLlmClient(name: string): LlmClient { + return { + provider: "local_only" as LlmProviderName, + model: name, + canStream: false, + async complete(_messages: LlmMessage[] | string, _opts?: LlmCallOptions) { + return { + text: "{}", + provider: "local_only" as LlmProviderName, + model: name, + servedBy: "local_only" as LlmProviderName, + durationMs: 0, + }; + }, + async completeJson() { + return { + value: {} as T, + raw: "{}", + provider: "local_only" as LlmProviderName, + model: name, + servedBy: "local_only" as LlmProviderName, + durationMs: 0, + }; + }, + async *stream() { + yield { delta: "", done: true }; + }, + stats(): LlmClientStats { + return { + requests: 0, + hostFallbacks: 0, + failures: 0, + retries: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + lastOkAt: null, + lastError: null, + lastStatus: null, + }; + }, + resetStats() {}, + async close() {}, + }; +} + +let dbHandle: TmpDbHandle | null = null; + +function buildDepsWithDistinctLlms( + h: TmpDbHandle, + lightweight: boolean, +): PipelineDeps { + return { + agent: "openclaw", + home: resolveHome("openclaw", "/tmp/memos-issue-2148-test"), + config: { + ...DEFAULT_CONFIG, + algorithm: { + ...DEFAULT_CONFIG.algorithm, + lightweightMemory: { + ...DEFAULT_CONFIG.algorithm.lightweightMemory, + enabled: lightweight, + }, + }, + }, + db: h.db, + repos: h.repos, + llm: fakeLlmClient("main-llm"), + reflectLlm: fakeLlmClient("skill-evolver-llm"), + l3Llm: fakeLlmClient("l3-llm"), + embedder: fakeEmbedder({ dimensions: 384 }), + log: rootLogger.child({ channel: "test.issue-2148" }), + namespace: { agentKind: "openclaw", profileId: "main" }, + now: () => 1_700_000_000_000, + }; +} + +beforeEach(() => { + dbHandle = makeTmpDb(); + captureRunnerCalls.length = 0; +}); + +afterEach(() => { + dbHandle?.cleanup(); + dbHandle = null; +}); + +describe("pipeline/deps captureRunner wiring (issue #2148)", () => { + it("passes the main llm — not reflectLlm — as the capture runner's reflectLlm slot (normal mode)", () => { + const buses = buildPipelineBuses(); + const deps = buildDepsWithDistinctLlms(dbHandle!, false); + const algorithm = extractAlgorithmConfig(deps); + const session = buildPipelineSession(deps, buses.session); + buildPipelineSubscribers(deps, buses, algorithm, session); + + expect(captureRunnerCalls).toHaveLength(1); + const call = captureRunnerCalls[0]; + + // Both slots use the same rate-limited main-model client. + expect(call.llm?.model).toBe("main-llm"); + expect(call.reflectLlm).toBe(call.llm); + + // Regression guard: even though `deps.reflectLlm` is a distinct + // skill-evolver model (with e.g. enableThinking=true in real use), + // the capture pipeline must ignore it and use the main llm — batch + // reflection is a JSON-output task and cannot tolerate thinking + // tags. See issue #2148. + expect(call.reflectLlm?.model).toBe("main-llm"); + expect(call.reflectLlm?.model).not.toBe("skill-evolver-llm"); + }); + + it("passes the main llm as reflectLlm even in lightweight mode", () => { + // Lightweight mode still constructs the capture runner (it's what + // handles the lite/lightweight capture paths); the reflect pass is + // gated separately. The wiring guard must hold either way so a + // future flip of the flag can't reintroduce the bug. + const buses = buildPipelineBuses(); + const deps = buildDepsWithDistinctLlms(dbHandle!, true); + const algorithm = extractAlgorithmConfig(deps); + const session = buildPipelineSession(deps, buses.session); + buildPipelineSubscribers(deps, buses, algorithm, session); + + expect(captureRunnerCalls).toHaveLength(1); + const call = captureRunnerCalls[0]; + expect(call.llm?.model).toBe("main-llm"); + expect(call.reflectLlm).toBe(call.llm); + expect(call.reflectLlm?.model).toBe("main-llm"); + }); +}); From 2bcafc85895dba29b3c17975ee14cc9265ac9837 Mon Sep 17 00:00:00 2001 From: Memtensor-AI Date: Thu, 23 Jul 2026 20:39:14 +0800 Subject: [PATCH 31/33] test(logging): verify standalone bootstrap writes memos.log (#2147) (#2150) * fix(local-plugin): call initLogger in bootstrapMemoryCoreFull `bootstrapMemoryCoreFull` in `apps/memos-local-plugin/core/pipeline/memory-core.ts` never invoked `initLogger(config, home)` after `loadConfig(home)`. The logger stayed in the `bootstrapConsoleOnly()` fallback (console + memory buffer + SSE broadcast only), so the `FileRotatingTransport` / `JsonlEventsTransport` sinks were never wired and `MEMOS_HOME/logs/` (memos.log, error.log, audit.log, llm.jsonl, perf.jsonl, events.jsonl) was left empty even though `config.logging.file.enabled` defaults to true. The fix adds `initLogger` to the logger import and calls it after the config is resolved and before the first `rootLogger.child(...)` call. `initLogger` is idempotent and swaps the active root in place, so existing child handles keep working. A new regression test `tests/unit/pipeline/bootstrap-init-logger.test.ts` boots the pipeline against a tmp home, emits a marker line, and asserts that `memos.log` is created on disk and contains the marker. It fails on the previous code (ENOENT for memos.log) and passes with this fix. Fixes #2147 * style(hermes): format daemon manager --------- Co-authored-by: MemOS AutoDev Co-authored-by: jiachengzhen --- .../hermes/memos_provider/daemon_manager.py | 1 - .../pipeline/bootstrap-init-logger.test.ts | 74 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/memos-local-plugin/tests/unit/pipeline/bootstrap-init-logger.test.ts diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py index 98d242731..0666fde72 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py @@ -215,7 +215,6 @@ def _rebuild_if_stale() -> bool: return False - def ensure_bridge_running(*, probe_only: bool = False) -> bool: """Return True when the bridge is (or can be) operational. diff --git a/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-init-logger.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-init-logger.test.ts new file mode 100644 index 000000000..703703dd8 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/pipeline/bootstrap-init-logger.test.ts @@ -0,0 +1,74 @@ +/** + * Regression test for issue #2147: + * The standalone bridge must request logger initialization after config is + * resolved so file transports (memos.log, error.log, audit.log, llm.jsonl, + * perf.jsonl, events.jsonl) are created on disk. Before the fix, the bridge + * left the logger in `bootstrapConsoleOnly()` mode, so `home.logsDir` remained + * empty even though `config.logging.file.enabled` defaults to `true`. + * + * The assertion is deliberately tight: emit a distinctive marker line + * through `rootLogger.child(...)` after bootstrap, flush the logger, then + * confirm the marker landed in `memos.log`. Any regression that leaves the + * console-only sinks in place would make `memos.log` either missing or + * marker-free. + */ + +import { afterEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; + +import { makeTmpHome, type TmpHomeContext } from "../../helpers/tmp-home.js"; +import { bootstrapMemoryCoreFull } from "../../../core/pipeline/memory-core.js"; +import { + initTestLogger, + rootLogger, + shutdownLogger, +} from "../../../core/logger/index.js"; +import type { MemoryCore } from "../../../agent-contract/memory-core.js"; + +describe("bootstrapMemoryCoreFull -> initLogger", () => { + let home: TmpHomeContext | null = null; + let core: MemoryCore | null = null; + + afterEach(async () => { + if (core) await core.shutdown(); + await shutdownLogger(); + if (home) await home.cleanup(); + core = null; + home = null; + initTestLogger(); + }); + + it("wires file transports when initLogging is set", async () => { + home = await makeTmpHome({ agent: "openclaw" }); + + // Sanity check: fixture leaves logsDir empty before bootstrap. + const before = await fs.readdir(home.home.logsDir); + expect(before).toEqual([]); + + const result = await bootstrapMemoryCoreFull({ + agent: "openclaw", + home: home.home, + config: home.config, + pkgVersion: "issue-2147-test", + initLogging: true, + }); + core = result.core; + + // Distinctive marker so we can grep memos.log without depending on + // whichever config warnings bootstrap emitted. + const marker = "issue-2147.marker.line.abcdef"; + rootLogger.child({ channel: "core.pipeline.bootstrap" }).info(marker, { + probe: "bootstrap-init-logger", + }); + await rootLogger.flush(); + + const memosLogPath = join(home.home.logsDir, "memos.log"); + const stat = await fs.stat(memosLogPath); + expect(stat.isFile()).toBe(true); + expect(stat.size).toBeGreaterThan(0); + + const text = await fs.readFile(memosLogPath, "utf8"); + expect(text).toContain(marker); + }); +}); From 8346598d80f8f53ed6b79c568467517e141e98ca Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:54:06 +0800 Subject: [PATCH 32/33] fix: align generated skill guidance (#2100) * feat(api): align OpenMem v1 SDK with cloud API * fix: align generated skill guidance * feat(api): align OpenMem v1 SDK with cloud API * fix(memos-local): include json hint in user messages (#1756) Co-authored-by: shinetata <149466187+shinetata@users.noreply.github.com> --------- Co-authored-by: dingliang <2650876010@qq.com> Co-authored-by: Qi Weng Co-authored-by: de1ty <7804799+de1tydev@users.noreply.github.com> Co-authored-by: shinetata <149466187+shinetata@users.noreply.github.com> Co-authored-by: jiachengzhen --- apps/memos-local-openclaw/src/skill/generator.ts | 12 ++++++++---- .../tests/skill-prompt-policy.test.ts | 10 ++++++++++ packages/memos-core/src/skill/generator.ts | 12 ++++++++---- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/memos-local-openclaw/src/skill/generator.ts b/apps/memos-local-openclaw/src/skill/generator.ts index b1b77578e..a5e6373ea 100644 --- a/apps/memos-local-openclaw/src/skill/generator.ts +++ b/apps/memos-local-openclaw/src/skill/generator.ts @@ -28,6 +28,13 @@ This Skill is special: it comes from real execution experience — every step wa - The frontmatter description (~100 words) is ALWAYS in the agent's context — it must be self-sufficient for deciding whether to use this skill. - The SKILL.md body loads when triggered — keep it under 400 lines, focused, no fluff. - If the task involved large configs/scripts, mention them but DON'T inline everything — just reference that scripts/ or references/ may contain them. +- For multi-variant skills, keep only the shared workflow and variant-selection guidance in SKILL.md; move framework-, provider-, or domain-specific details into separate references/ files. +- Do not create README.md, CHANGELOG.md, INSTALLATION_GUIDE.md, or other standalone documentation. A skill consists of SKILL.md plus only the scripts/, references/, or assets/ companions it genuinely needs. + +### Frontmatter rules +- name: Use 2-4 words in English kebab-case so the identifier stays concise. +- description: Include both what the skill does and when to use it, with concrete scenarios, keywords, and alternative phrasings because this is the primary trigger. +- Include ONLY \`name\` and \`description\` in frontmatter; do not add metadata or other fields. ### Description as trigger mechanism The description field decides whether the agent activates this skill. Write it "proactively": @@ -45,6 +52,7 @@ The description field decides whether the agent activates this skill. Write it " - Abstract the mistake, not just the fix: capture the diagnostic method that found the problem, not only the specific error message or patch from this task - Do not make a specific project name, product version, branch, or one-off rollback decision a required part of the skill unless the future task truly depends on it - Keep real commands/code/config from the task record — these are verified to work +- Only include information the agent does not already know; omit generic explanations that add tokens without improving execution. ### Language matching (CRITICAL) You MUST write the ENTIRE skill in the SAME language as the user's messages in the task record. @@ -63,16 +71,12 @@ Output ONLY the complete SKILL.md content. No extra text before or after. --- name: "{NAME}" description: "{A natural, proactive description. 60-120 words. Cover what it does + multiple phrasings/scenarios that should trigger it. Be pushy about triggering — list keywords, alternative descriptions, edge-case phrasings.}" -metadata: {{ "openclaw": {{ "emoji": "{emoji}" }} }} --- # {Title — clear, action-oriented} {One sentence: what this skill helps you do and why it's valuable} -## When to use this skill -{2-4 bullet points describing the scenarios. Focus on the user's INTENT, not just keywords. Example: "When you need to get a Node app running reliably in a container and want to avoid common pitfalls like bloated images or missing health checks."} - ## Steps {Numbered or sectioned steps extracted from the task. EVERY step actually performed must be included — do NOT skip or generalize away concrete steps like "configure security groups", "set environment variables", etc. For each step: 1. What to do (keep inline code short — if a step involves a long script or config, write a brief summary here and say "see scripts/ for the complete script") diff --git a/apps/memos-local-openclaw/tests/skill-prompt-policy.test.ts b/apps/memos-local-openclaw/tests/skill-prompt-policy.test.ts index 3969c8933..8ba2015e7 100644 --- a/apps/memos-local-openclaw/tests/skill-prompt-policy.test.ts +++ b/apps/memos-local-openclaw/tests/skill-prompt-policy.test.ts @@ -18,4 +18,14 @@ describe("skill prompt policy", () => { expect(STEP1_SKILL_MD_PROMPT).toContain("Abstract the mistake"); expect(STEP1_SKILL_MD_PROMPT).toContain("project name, product version"); }); + + it("keeps generated skills concise and discoverable", () => { + expect(STEP1_SKILL_MD_PROMPT).toContain("2-4 words in English kebab-case"); + expect(STEP1_SKILL_MD_PROMPT).toContain("ONLY `name` and `description`"); + expect(STEP1_SKILL_MD_PROMPT).not.toContain("metadata:"); + expect(STEP1_SKILL_MD_PROMPT).not.toContain("## When to use this skill"); + expect(STEP1_SKILL_MD_PROMPT).toContain("multi-variant skills"); + expect(STEP1_SKILL_MD_PROMPT).toContain("README.md"); + expect(STEP1_SKILL_MD_PROMPT).toContain("does not already know"); + }); }); diff --git a/packages/memos-core/src/skill/generator.ts b/packages/memos-core/src/skill/generator.ts index 89cf1e2e6..17001994b 100644 --- a/packages/memos-core/src/skill/generator.ts +++ b/packages/memos-core/src/skill/generator.ts @@ -28,6 +28,13 @@ This Skill is special: it comes from real execution experience — every step wa - The frontmatter description (~100 words) is ALWAYS in the agent's context — it must be self-sufficient for deciding whether to use this skill. - The SKILL.md body loads when triggered — keep it under 400 lines, focused, no fluff. - If the task involved large configs/scripts, mention them but DON'T inline everything — just reference that scripts/ or references/ may contain them. +- For multi-variant skills, keep only the shared workflow and variant-selection guidance in SKILL.md; move framework-, provider-, or domain-specific details into separate references/ files. +- Do not create README.md, CHANGELOG.md, INSTALLATION_GUIDE.md, or other standalone documentation. A skill consists of SKILL.md plus only the scripts/, references/, or assets/ companions it genuinely needs. + +### Frontmatter rules +- name: Use 2-4 words in English kebab-case so the identifier stays concise. +- description: Include both what the skill does and when to use it, with concrete scenarios, keywords, and alternative phrasings because this is the primary trigger. +- Include ONLY \`name\` and \`description\` in frontmatter; do not add metadata or other fields. ### Description as trigger mechanism The description field decides whether the agent activates this skill. Write it "proactively": @@ -42,6 +49,7 @@ The description field decides whether the agent activates this skill. Write it " - Seeing yourself write ALWAYS or NEVER in caps is a yellow flag — rephrase with reasoning instead - Generalize from the specific task so the skill works for similar future scenarios, don't over-fit to this exact project - Keep real commands/code/config from the task record — these are verified to work +- Only include information the agent does not already know; omit generic explanations that add tokens without improving execution. ### Language matching (CRITICAL) You MUST write the ENTIRE skill in the SAME language as the user's messages in the task record. @@ -60,16 +68,12 @@ Output ONLY the complete SKILL.md content. No extra text before or after. --- name: "{NAME}" description: "{A natural, proactive description. 60-120 words. Cover what it does + multiple phrasings/scenarios that should trigger it. Be pushy about triggering — list keywords, alternative descriptions, edge-case phrasings.}" -metadata: {{ "openclaw": {{ "emoji": "{emoji}" }} }} --- # {Title — clear, action-oriented} {One sentence: what this skill helps you do and why it's valuable} -## When to use this skill -{2-4 bullet points describing the scenarios. Focus on the user's INTENT, not just keywords. Example: "When you need to get a Node app running reliably in a container and want to avoid common pitfalls like bloated images or missing health checks."} - ## Steps {Numbered or sectioned steps extracted from the task. EVERY step actually performed must be included — do NOT skip or generalize away concrete steps like "configure security groups", "set environment variables", etc. For each step: 1. What to do (keep inline code short — if a step involves a long script or config, write a brief summary here and say "see scripts/ for the complete script") From b06ff989eaff10417122caaa6b49c5a5d614c07e Mon Sep 17 00:00:00 2001 From: bittergreen Date: Fri, 24 Jul 2026 10:40:55 +0800 Subject: [PATCH 33/33] fix: Add dedicated document LLM, improve Markdown detection and context-aware structured extraction --- src/memos/api/config.py | 26 +++- src/memos/configs/mem_reader.py | 7 +- src/memos/mem_reader/multi_modal_struct.py | 9 +- .../read_multi_modal/file_content_parser.py | 50 ++++++- .../read_multi_modal/multi_modal_parser.py | 7 +- src/memos/templates/mem_reader_prompts.py | 46 +++++-- tests/api/test_llm_provider_config.py | 51 +++++++ tests/mem_reader/test_document_parser_llm.py | 128 ++++++++++++++++++ 8 files changed, 303 insertions(+), 21 deletions(-) create mode 100644 tests/mem_reader/test_document_parser_llm.py diff --git a/src/memos/api/config.py b/src/memos/api/config.py index b3e45dfb2..43ad586dd 100644 --- a/src/memos/api/config.py +++ b/src/memos/api/config.py @@ -400,7 +400,7 @@ def get_activation_config() -> dict[str, Any]: @staticmethod def get_memreader_config() -> dict[str, Any]: - """Get MemReader configuration for chat/doc extraction (fine-tuned 0.6B model). + """Get the main MemReader configuration for text/chat extraction. When MEMREADER_GENERAL_MODEL is configured (i.e. a separate stable LLM exists), the backup client is automatically enabled so that primary failures (self-deployed @@ -444,7 +444,7 @@ def get_qwen_llm_config() -> dict[str, Any] | None: @staticmethod def get_memreader_general_llm_config() -> dict[str, Any]: - """Get general LLM configuration for non-chat/doc tasks. + """Get general LLM configuration for non-primary extraction tasks. Used for: hallucination filter, memory rewrite, memory merge, tool trajectory extraction, skill memory extraction. @@ -478,6 +478,24 @@ def get_image_parser_llm_config() -> dict[str, Any]: # Fallback to general_llm config (which itself falls back to OpenAI) return APIConfig.get_memreader_general_llm_config() + @staticmethod + def get_document_parser_llm_config() -> dict[str, Any] | None: + """Get the dedicated LLM configuration for document extraction. + + The provider endpoint and credentials are selected from QWEN_* or + OPENAI_* according to DOCUMENT_PARSER_MODEL. + + Fallback chain: DOCUMENT_PARSER_MODEL -> MEMREADER_GENERAL_MODEL. + Returns None when neither model is configured. + """ + document_model = os.getenv("DOCUMENT_PARSER_MODEL") or os.getenv("MEMREADER_GENERAL_MODEL") + if not document_model: + return None + return APIConfig._build_provider_llm_config( + document_model, + temperature=0.8, + ) + @staticmethod def get_preference_extractor_llm_config() -> dict[str, Any]: """Get LLM configuration for preference extraction. @@ -994,6 +1012,8 @@ def get_product_default_config() -> dict[str, Any]: "general_llm": APIConfig.get_memreader_general_llm_config(), # Image parser LLM (requires vision model) "image_parser_llm": APIConfig.get_image_parser_llm_config(), + # Dedicated LLM for document chunk extraction + "document_parser_llm": APIConfig.get_document_parser_llm_config(), # Preference extractor LLM. Reader falls back to general_llm when unset. "preference_extractor_llm": APIConfig.get_preference_extractor_llm_config() if os.getenv("PREFERENCE_EXTRACTOR_MODEL") @@ -1127,6 +1147,8 @@ def create_user_config(user_name: str, user_id: str) -> tuple["MOSConfig", "Gene "general_llm": APIConfig.get_memreader_general_llm_config(), # Image parser LLM (requires vision model) "image_parser_llm": APIConfig.get_image_parser_llm_config(), + # Dedicated LLM for document chunk extraction + "document_parser_llm": APIConfig.get_document_parser_llm_config(), # Preference extractor LLM. Reader falls back to general_llm when unset. "preference_extractor_llm": APIConfig.get_preference_extractor_llm_config() if os.getenv("PREFERENCE_EXTRACTOR_MODEL") diff --git a/src/memos/configs/mem_reader.py b/src/memos/configs/mem_reader.py index c7ee93ad6..5bbcfa0c0 100644 --- a/src/memos/configs/mem_reader.py +++ b/src/memos/configs/mem_reader.py @@ -25,7 +25,8 @@ def parse_datetime(cls, value): return value llm: LLMConfigFactory = Field( - ..., description="LLM configuration for chat/doc memory extraction (fine-tuned model)" + ..., + description="Main LLM configuration for standard text/chat memory extraction", ) general_llm: LLMConfigFactory | None = Field( default=None, @@ -36,6 +37,10 @@ def parse_datetime(cls, value): default=None, description="Vision LLM for image parsing. Falls back to general_llm if not set.", ) + document_parser_llm: LLMConfigFactory | None = Field( + default=None, + description="Dedicated LLM for document content extraction", + ) preference_extractor_llm: LLMConfigFactory | None = Field( default=None, description="LLM for preference extraction. Falls back to general_llm if not set.", diff --git a/src/memos/mem_reader/multi_modal_struct.py b/src/memos/mem_reader/multi_modal_struct.py index 8a7bc2da1..c46331f1d 100644 --- a/src/memos/mem_reader/multi_modal_struct.py +++ b/src/memos/mem_reader/multi_modal_struct.py @@ -71,12 +71,19 @@ def __init__(self, config: MultiModalStructMemReaderConfig): if config.image_parser_llm is not None else self.general_llm ) + # Document parser LLM (separate from the fine-tuned chat extractor) + self.document_parser_llm = ( + LLMFactory.from_config(config.document_parser_llm) + if config.document_parser_llm is not None + else None + ) # Initialize MultiModalParser for routing to different parsers - # Pass image_parser_llm for image parsing + # Pass dedicated image/document LLMs to their corresponding parsers self.multi_modal_parser = MultiModalParser( embedder=self.embedder, llm=self.llm, image_parser_llm=self.image_parser_llm, + document_parser_llm=self.document_parser_llm, parser=None, direct_markdown_hostnames=direct_markdown_hostnames, ) diff --git a/src/memos/mem_reader/read_multi_modal/file_content_parser.py b/src/memos/mem_reader/read_multi_modal/file_content_parser.py index 0629eda97..a6aba3aa0 100644 --- a/src/memos/mem_reader/read_multi_modal/file_content_parser.py +++ b/src/memos/mem_reader/read_multi_modal/file_content_parser.py @@ -100,7 +100,7 @@ def _get_doc_llm_response( def _handle_url(self, url_str: str, filename: str) -> tuple[str, str | None, bool]: """Download and parse file from URL.""" try: - from urllib.parse import urlparse + from urllib.parse import unquote, urlparse import requests @@ -118,7 +118,13 @@ def _handle_url(self, url_str: str, filename: str) -> tuple[str, str | None, boo return response.text, None, True file_ext = os.path.splitext(filename)[1].lower() - if file_ext in [".md", ".markdown", ".txt"] or self._is_oss_md(url_str): + url_path_ext = os.path.splitext(unquote(parsed_url.path))[1].lower() + markdown_extensions = {".md", ".markdown", ".txt"} + if ( + file_ext in markdown_extensions + or url_path_ext in markdown_extensions + or self._is_oss_md(url_str) + ): return response.text, None, True with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=file_ext) as temp_file: temp_file.write(response.content) @@ -333,7 +339,7 @@ def __init__( Args: embedder: Embedder for generating embeddings - llm: Optional LLM for fine mode processing + llm: Optional dedicated LLM for document extraction parser: Optional parser for parsing file contents direct_markdown_hostnames: List of hostnames that should return markdown directly without parsing. If None, reads from FILE_PARSER_DIRECT_MARKDOWN_HOSTNAMES @@ -739,6 +745,13 @@ def parse_fine( logger.info( f"[Chunker: FileContentParser] Extracted {len(headers)} headers from markdown" ) + document_context = self._build_markdown_document_context(headers, filename) + if document_context: + context_parts = [] + if message_text_context: + context_parts.append(f"Related message context:\n{message_text_context}") + context_parts.append(document_context) + message_text_context = "\n\n".join(context_parts) # Extract and process images from parsed_text if is_markdown and parsed_text and self.image_parser: @@ -1050,6 +1063,37 @@ def _extract_markdown_headers(self, text: str) -> dict[int, dict]: logger.info(f"[Chunker: FileContentParser] Extracted {len(headers)} headers from markdown") return headers + @staticmethod + def _build_markdown_document_context( + headers: dict[int, dict], + filename: str, + ) -> str | None: + """Build document-level context from a Markdown filename and H1 titles.""" + h1_titles = [] + seen_titles = set() + for header in headers.values(): + if header.get("level") != 1: + continue + title = str(header.get("title", "")).strip() + if not title or title in seen_titles: + continue + seen_titles.add(title) + h1_titles.append(title) + + if not filename and not h1_titles: + return None + + lines = [ + "Document-level context for disambiguation only. " + "Do not create memories from this context unless the current chunk supports them." + ] + if filename: + lines.append(f"Filename: {filename}") + if h1_titles: + lines.append("Top-level Markdown headings:") + lines.extend(f"- {title}" for title in h1_titles) + return "\n".join(lines) + def _get_header_context( self, text: str, image_position: int, headers: dict[int, dict] ) -> list[str]: diff --git a/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py b/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py index a08aadc0c..bdcaee6ee 100644 --- a/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py +++ b/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py @@ -38,6 +38,7 @@ def __init__( embedder: BaseEmbedder, llm: BaseLLM | None = None, image_parser_llm: BaseLLM | None = None, + document_parser_llm: BaseLLM | None = None, parser: Any | None = None, direct_markdown_hostnames: list[str] | None = None, ): @@ -46,9 +47,10 @@ def __init__( Args: embedder: Embedder for generating embeddings - llm: Optional LLM for fine mode processing (chat/doc extraction) + llm: Optional main LLM for standard text/chat extraction. image_parser_llm: Optional vision LLM for image parsing. Falls back to llm if not provided. + document_parser_llm: Optional dedicated LLM for document extraction. parser: Optional parser for parsing file contents direct_markdown_hostnames: List of hostnames that should return markdown directly without parsing. If None, reads from FILE_PARSER_DIRECT_MARKDOWN_HOSTNAMES @@ -58,6 +60,7 @@ def __init__( self.llm = llm # Image parser LLM (requires vision model), falls back to main llm self.image_parser_llm = image_parser_llm if image_parser_llm is not None else llm + self.document_parser_llm = document_parser_llm self.parser = parser # Initialize parsers for different message types @@ -71,7 +74,7 @@ def __init__( self.image_parser = ImageParser(embedder, self.image_parser_llm) self.file_content_parser = FileContentParser( embedder, - llm, + self.document_parser_llm, parser, direct_markdown_hostnames=direct_markdown_hostnames, image_parser=self.image_parser, diff --git a/src/memos/templates/mem_reader_prompts.py b/src/memos/templates/mem_reader_prompts.py index 63e4c1538..3055ca356 100644 --- a/src/memos/templates/mem_reader_prompts.py +++ b/src/memos/templates/mem_reader_prompts.py @@ -228,16 +228,27 @@ Your task is to process a document chunk and generate a single, structured JSON object. Please perform: -1. Identify key information that reflects factual content, insights, decisions, or implications from the documents — including any notable themes, conclusions, or data points. Allow a reader to fully understand the essence of the chunk without reading the original text. -2. Resolve all time, person, location, and event references clearly: +1. Organize the document chunk into useful retrieval memories using the smallest complete record as the unit. The memories should be minimally redundant and collectively cover the important source information. + - A memory should answer one natural question on its own. Keep attributes, qualifiers, values, and relationships that belong to the same record together. + - Split content when it contains distinct subjects, rules, conditions, decisions, events, or data records that are independently useful for retrieval. + - But do not split one fact, rule, record, or short list into fragments merely because it contains multiple fields or peer items. +2. Treat tables, lists, statistics, measurements, prices, dates, percentages, rankings, identifiers, and other structured or numerical content as high-priority information. + - Preserve every row and the relationship between its row label and column values, including units, currencies, signs, ranges, and original category or rank names. + - For a data table, normally create one memory per complete row and include the row label plus all related column values in that memory. Do not split one row into separate memories for individual cells or columns. + - Keep a simple list of peer items together when the items share one subject and have no item-specific conditions, values, or explanations. Split list entries only when they carry independently meaningful details. + - Never replace exact values or lower rows with vague summaries such as "and so on", "decreasing progressively", or "other entries are similar". + - Do not calculate, normalize, rename, reorder, or "correct" values and labels unless the source explicitly provides the correction. +3. Identify key information that reflects factual content, insights, decisions, or implications from the documents — including any notable themes, conclusions, or data points. Allow a reader to fully understand the essence of the chunk without reading the original text. +4. Resolve all time, person, location, and event references clearly: - Convert relative time expressions (e.g., “last year,” “next quarter”) into absolute dates if context allows. - Clearly distinguish between event time and document time. - If uncertainty exists, state it explicitly (e.g., “around 2024,” “exact date unclear”). + - Never invent a missing year or date. Use document-level context only when it unambiguously identifies the same event or subject. - Include specific locations if mentioned. - Resolve all pronouns, aliases, and ambiguous references into full names or identities. - Disambiguate entities with the same name if applicable. -3. Always write from a third-person perspective, referring to the subject or content clearly rather than using first-person ("I", "me", "my"). -4. Do not omit any information that is likely to be important or memorable from the document summaries. +5. Always write from a third-person perspective, referring to the subject or content clearly rather than using first-person ("I", "me", "my"). +6. Do not omit any information that is likely to be important or memorable from the document summaries. - Include all key facts, insights, emotional tones, and plans — even if they seem minor. - Prioritize completeness and fidelity over conciseness. - Do not generalize or skip details that could be contextually meaningful. @@ -249,7 +260,7 @@ { "key": , "memory_type": "LongTermMemory", - "value": , + "value": , "tags": } ... @@ -263,7 +274,7 @@ {custom_tags_prompt} -If given context, use it as a supplement to the document information extraction; if no context is given, directly process the document information. +Use the reference context only to disambiguate the current chunk, such as identifying the document, event, subject, or year. Do not create a memory solely from the reference context unless the same fact is supported by the current document chunk. If no context is given, directly process the document information. Reference context: {context} @@ -276,16 +287,27 @@ 您的任务是处理文档片段,并生成一个结构化的 JSON 列表对象。 请执行以下操作: -1. 识别反映文档中事实内容、见解、决策或含义的关键信息——包括任何显著的主题、结论或数据点,使读者无需阅读原文即可充分理解该片段的核心内容。 -2. 清晰解析所有时间、人物、地点和事件的指代: +1. 以“最小完整记录”为单位,将文档片段组织成便于检索的多条记忆;各条记忆应尽量不重复,并合起来覆盖原文中的重要信息。 + - 一条记忆应能独立回答一个自然问题;属于同一记录的属性、限定条件、数值和关系应保留在一起。 + - 当内容涉及不同主题、规则、条件、决策、事件或数据记录,并且分别具有独立检索价值时,需要进行拆分。 + - 但不要仅仅因为一个事实、规则、记录或简短列表包含多个字段或并列项,就把它机械拆成多个片段。 +2. 对表格、列表、统计数据、计量值、价格、日期、百分比、排名、编号以及其他结构化或数值信息给予额外关注。 + - 完整保留每一行,以及行名称与各列数值之间的对应关系,包括单位、币种、正负号、范围和原始类别或名次名称。 + - 对数据表格,通常每一行生成一条记忆,并在该条记忆中同时保留行名称及所有相关列值;不得把同一行拆成多条记忆,分别保存不同单元格或列。 + - 当普通并列列表中的各项属于同一主题,且没有各自独立的条件、数值或解释时,普通并列列表应按共同主题合并为一条记忆;只有列表项包含可独立检索的重要细节时才拆分。 + - 不得用模糊概述替代精确数值或后续行,例如“依次递减”“等等”“其他项目类似”。 + - 除非原文明确给出修正,否则不得自行计算、归一化、改名、重排或“纠正”数值和标签。 +3. 识别反映文档中事实内容、见解、决策或含义的关键信息——包括任何显著的主题、结论或数据点,使读者无需阅读原文即可充分理解该片段的核心内容。 +4. 清晰解析所有时间、人物、地点和事件的指代: - 如果上下文允许,将相对时间表达(如“去年”、“下一季度”)转换为绝对日期。 - 明确区分事件时间和文档时间。 - 如果存在不确定性,需明确说明(例如,“约2024年”,“具体日期不详”)。 + - 不得编造缺失的年份或日期。只有当文档级上下文能够无歧义地指向同一事件或主题时,才可据此补全年份。 - 若提及具体地点,请包含在内。 - 将所有代词、别名和模糊指代解析为全名或明确身份。 - 如有同名实体,需加以区分。 -3. 始终以第三人称视角撰写,清晰指代主题或内容,避免使用第一人称(“我”、“我们”、“我的”)。 -4. 不要遗漏文档摘要中可能重要或值得记忆的任何信息。 +5. 始终以第三人称视角撰写,清晰指代主题或内容,避免使用第一人称(“我”、“我们”、“我的”)。 +6. 不要遗漏文档摘要中可能重要或值得记忆的任何信息。 - 包括所有关键事实、见解、情感基调和计划——即使看似微小。 - 优先考虑完整性和保真度,而非简洁性。 - 不要泛化或跳过可能具有上下文意义的细节。 @@ -297,7 +319,7 @@ { "key": <字符串,`value` 字段的简洁标题>, "memory_type": "LongTermMemory", - "value": <一段清晰准确的段落,全面总结文档片段中的主要观点、论据和信息——若输入摘要为英文,则用英文;若为中文,则用中文>, + "value": <一条清晰、准确、自包含的最小完整记录,并保留紧密相关的字段、精确结构化数值及其对应关系——若输入摘要为英文,则用英文;若为中文,则用中文>, "tags": <相关主题关键词列表(例如,["截止日期", "团队", "计划"])> } ... @@ -311,7 +333,7 @@ {custom_tags_prompt} -如果给定了上下文,就结合上下文信息作为文档信息提取的补充,如果没有给定上下文,请直接处理文档信息。 +参考上下文只用于消除当前片段中的歧义,例如确定文档、事件、主题或年份。除非当前文档片段本身也支持同一事实,否则不得仅依据参考上下文创建记忆。如果没有上下文,请直接处理文档信息。 参考的上下文: {context} diff --git a/tests/api/test_llm_provider_config.py b/tests/api/test_llm_provider_config.py index 61292c438..fbce5812b 100644 --- a/tests/api/test_llm_provider_config.py +++ b/tests/api/test_llm_provider_config.py @@ -32,6 +32,57 @@ def test_task_openai_model_uses_openai_provider_env(monkeypatch): assert config["config"]["api_base"] == "https://openai.example/v1" +def test_document_parser_model_uses_provider_env_and_dedicated_temperature(monkeypatch): + monkeypatch.setenv("DOCUMENT_PARSER_MODEL", "qwen3.6-flash") + monkeypatch.setenv("QWEN_API_KEY", "qwen-key") + monkeypatch.setenv("QWEN_API_BASE", "https://dashscope.example/v1") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + config = APIConfig.get_document_parser_llm_config() + + assert config["backend"] == "qwen" + assert config["config"]["model_name_or_path"] == "qwen3.6-flash" + assert config["config"]["api_key"] == "qwen-key" + assert config["config"]["api_base"] == "https://dashscope.example/v1" + assert config["config"]["temperature"] == 0.8 + assert config["config"]["extra_body"] == {"enable_thinking": False} + + +def test_document_parser_model_falls_back_to_general_model(monkeypatch): + monkeypatch.delenv("DOCUMENT_PARSER_MODEL", raising=False) + monkeypatch.setenv("MEMREADER_GENERAL_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + config = APIConfig.get_document_parser_llm_config() + + assert config["backend"] == "openai" + assert config["config"]["model_name_or_path"] == "gpt-4.1-mini" + assert config["config"]["temperature"] == 0.8 + + +def test_document_parser_model_does_not_fall_back_to_main_memreader(monkeypatch): + monkeypatch.delenv("DOCUMENT_PARSER_MODEL", raising=False) + monkeypatch.delenv("MEMREADER_GENERAL_MODEL", raising=False) + monkeypatch.setenv("MEMRADER_MODEL", "qwen3-0.6B") + + assert APIConfig.get_document_parser_llm_config() is None + + +def test_product_config_wires_document_parser_model(monkeypatch): + monkeypatch.setenv("DOCUMENT_PARSER_MODEL", "gpt-4.1-mini") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + monkeypatch.setenv("OPENAI_API_BASE", "https://openai.example/v1") + + reader_config = APIConfig.get_product_default_config()["mem_reader"]["config"] + + document_config = reader_config["document_parser_llm"] + assert document_config["backend"] == "openai" + assert document_config["config"]["model_name_or_path"] == "gpt-4.1-mini" + assert document_config["config"]["temperature"] == 0.8 + + def test_qwen_llm_only_uses_model_and_provider_endpoint_env(monkeypatch): monkeypatch.setenv("QWEN_MODEL", "qwen-flash") monkeypatch.setenv("QWEN_API_KEY", "qwen-key") diff --git a/tests/mem_reader/test_document_parser_llm.py b/tests/mem_reader/test_document_parser_llm.py new file mode 100644 index 000000000..1c40a321c --- /dev/null +++ b/tests/mem_reader/test_document_parser_llm.py @@ -0,0 +1,128 @@ +from unittest.mock import MagicMock, patch + +from memos.mem_reader.read_multi_modal.file_content_parser import FileContentParser +from memos.mem_reader.read_multi_modal.multi_modal_parser import MultiModalParser +from memos.templates.mem_reader_prompts import ( + SIMPLE_STRUCT_DOC_READER_PROMPT, + SIMPLE_STRUCT_DOC_READER_PROMPT_ZH, +) + + +def test_url_path_markdown_suffix_overrides_original_filename(): + parser = FileContentParser( + embedder=MagicMock(), + llm=MagicMock(), + direct_markdown_hostnames=[], + ) + response = MagicMock() + response.text = "# parsed markdown" + response.content = b"# parsed markdown" + + with ( + patch("requests.get", return_value=response), + patch( + "tempfile.NamedTemporaryFile", + side_effect=AssertionError("markdown URL should not be written to a temp file"), + ), + ): + text, temp_path, is_markdown = parser._handle_url( + "https://memos.example/api/download/document.md?signature=secret", + "original-document.docx", + ) + + assert text == "# parsed markdown" + assert temp_path is None + assert is_markdown is True + + +def test_multi_modal_parser_routes_files_to_dedicated_document_llm(): + main_llm = MagicMock(name="main_llm") + document_llm = MagicMock(name="document_llm") + + parser = MultiModalParser( + embedder=MagicMock(), + llm=main_llm, + document_parser_llm=document_llm, + ) + + assert parser.string_parser.llm is main_llm + assert parser.file_content_parser.llm is document_llm + + +def test_multi_modal_parser_does_not_fall_back_files_to_main_llm(): + main_llm = MagicMock(name="main_llm") + + parser = MultiModalParser( + embedder=MagicMock(), + llm=main_llm, + document_parser_llm=None, + ) + + assert parser.string_parser.llm is main_llm + assert parser.file_content_parser.llm is None + + +def test_document_prompts_use_complete_records_without_over_splitting(): + assert "smallest complete record" in SIMPLE_STRUCT_DOC_READER_PROMPT + assert "Preserve every row" in SIMPLE_STRUCT_DOC_READER_PROMPT + assert "Do not split one row into separate memories" in SIMPLE_STRUCT_DOC_READER_PROMPT + assert "Keep a simple list of peer items together" in SIMPLE_STRUCT_DOC_READER_PROMPT + assert "Never replace exact values" in SIMPLE_STRUCT_DOC_READER_PROMPT + + assert "最小完整记录" in SIMPLE_STRUCT_DOC_READER_PROMPT_ZH + assert "完整保留每一行" in SIMPLE_STRUCT_DOC_READER_PROMPT_ZH + assert "不得把同一行拆成多条记忆" in SIMPLE_STRUCT_DOC_READER_PROMPT_ZH + assert "普通并列列表应按共同主题合并" in SIMPLE_STRUCT_DOC_READER_PROMPT_ZH + assert "不得用模糊概述替代精确数值" in SIMPLE_STRUCT_DOC_READER_PROMPT_ZH + + +def test_markdown_h1_titles_are_passed_as_document_context(): + embedder = MagicMock() + embedder.embed.return_value = [[0.1]] + parser = FileContentParser( + embedder=embedder, + llm=MagicMock(), + parser=MagicMock(), + direct_markdown_hostnames=[], + ) + markdown = """# *2026 PPA 北京公开赛内部培训资料* + +## 非全局子标题 + +# 04 报名时间 + +即日起至 5 月 29 日 24:00 +""" + parser._handle_url = MagicMock(return_value=(markdown, None, True)) + parser._split_text = MagicMock(return_value=["# 04 报名时间\n即日起至 5 月 29 日 24:00"]) + parser._get_doc_llm_response = MagicMock( + return_value={ + "memory list": [ + { + "key": "报名截止时间", + "memory_type": "LongTermMemory", + "value": "报名截止时间为2026年5月29日24:00。", + "tags": ["报名", "时间"], + } + ], + "summary": "报名截止时间", + } + ) + + parser.parse_fine( + { + "type": "file", + "file": { + "file_data": "https://example.com/document.md", + "file_id": "file-1", + "filename": "training.docx", + }, + }, + {"user_id": "user-1", "session_id": "session-1"}, + ) + + context = parser._get_doc_llm_response.call_args.kwargs["message_text_context"] + assert "training.docx" in context + assert "*2026 PPA 北京公开赛内部培训资料*" in context + assert "04 报名时间" in context + assert "非全局子标题" not in context