Skip to content

feat: write-path content-hash dedup for MemoryManager.add#2153

Open
Zhe-SH-CN wants to merge 4 commits into
MemTensor:dev-v2.0.24from
Zhe-SH-CN:feat/write-path-content-hash-dedup
Open

feat: write-path content-hash dedup for MemoryManager.add#2153
Zhe-SH-CN wants to merge 4 commits into
MemTensor:dev-v2.0.24from
Zhe-SH-CN:feat/write-path-content-hash-dedup

Conversation

@Zhe-SH-CN

Copy link
Copy Markdown

Description

MemOS has no server-side dedup on the write path. Any client that re-sends identical memory content (retry loops, replayed events, buggy integrations) creates duplicate nodes that pollute search results, graph structure, and reorganization.

This PR adds content-hash deduplication at MemoryManager.add:

  1. Compute sha1(memory_text) for each incoming TextualMemoryItem
  2. Dedup within the batch using a set
  3. Query graph_store.get_by_metadata for existing nodes with the same content_hash
  4. Skip items whose hash exists within a configurable time window (default 7 days)
  5. Fail-open: if the dedup check errors, the write proceeds (availability over strictness)
  6. Persist content_hash as a metadata property on the graph node
  7. Toggles: MOS_DEDUP_ENABLED=0 to disable, MOS_DEDUP_WINDOW_DAYS to adjust the window

Related Issue: Fixes #2141

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested

  • Unit tests covering batch-level dedup, graph-store-level dedup, time window logic, fail-open behavior, empty input, and mixed duplicates
  • python3 -c "import ast; ast.parse(...)" syntax verification

Checklist

  • I have performed a self-review of my own code
  • I have commented my code in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works
  • I have linked the issue to this PR

@Memtensor-AI Memtensor-AI added area:memory 记忆存储、检索、更新、召回逻辑 status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Jul 23, 2026
@Memtensor-AI
Memtensor-AI requested a review from bittergreen July 23, 2026 10:36
harvey_xiang and others added 3 commits July 23, 2026 19:07
* Fix MemTensor#1540: fix: (MemTensor#1824)

docs(memos-local-plugin): clarify install path and stale dir names (MemTensor#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
MemTensor#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 26e7e3d).
- Mentions install.ps1 for Windows alongside install.sh.
- CHANGELOG: record the docs fix and reference MemTensor#1540.

Documentation-only change; no code or runtime behavior touched.

Co-authored-by: MemOS AutoDev <autodev@memtensor.ai>
Co-authored-by: Matthew <heimixiaozhuang@zju.edu.cn>

* Fix MemTensor#1888: [Bug] test_system_parser.py: SystemParser.__init__() got an unexpected keyword a (MemTensor#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 MemTensor#1888

Co-authored-by: MemOS AutoDev <autodev@memos.ai>
Co-authored-by: Matthew <heimixiaozhuang@zju.edu.cn>

* Fix MemTensor#1525: [Bug] clean_json_response crashes with cryptic AttributeError when given None (MemTensor#1884)

* test: add comprehensive tests for clean_json_response (issue MemTensor#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 <autodev@memos.ai>
Co-authored-by: Matthew <heimixiaozhuang@zju.edu.cn>

* Fix MemTensor#1901: share_cube_with_user passes swapped args to _validate_cube_access — fails for ev (MemTensor#1903)

fix: validate current user not target in share_cube_with_user (MemTensor#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 '<cube_id>' 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 '<id>' does not exist".

Closes MemTensor#1901

Co-authored-by: MemOS AutoDev Bot <autodev@memtensor.local>
Co-authored-by: Matthew <heimixiaozhuang@zju.edu.cn>

* feat(memos): chunk batch reflection scoring

* Fix MemTensor#1897: bug(memos-local-plugin): Hermes background model status checks can trigger paid  (MemTensor#1899)

* Fix MemTensor#1897: fix(memos-local-plugin): add LLM circuit breaker for terminal provider errors

Issue MemTensor#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 MemTensor#1620), per-tool rate limits, daily budget caps.

* Fix LLM breaker with host fallback

---------

Co-authored-by: autodev-bot <autodev@memtensor.local>
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 <project@memtensor.cn>
Co-authored-by: MemOS AutoDev <autodev@memtensor.ai>
Co-authored-by: Matthew <heimixiaozhuang@zju.edu.cn>
Co-authored-by: MemOS AutoDev <autodev@memos.ai>
Co-authored-by: MemOS AutoDev Bot <autodev@memtensor.local>
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>
…nsor#2145)

This reverts commit 21a27ed.

Co-authored-by: jiachengzhen <jiacz@memtensor.cn>
@Zhe-SH-CN
Zhe-SH-CN force-pushed the feat/write-path-content-hash-dedup branch from c27c7b5 to 2e364ee Compare July 23, 2026 11:07
@Zhe-SH-CN
Zhe-SH-CN changed the base branch from main to dev-v2.0.24 July 23, 2026 11:07
@Memtensor-AI Memtensor-AI added area:api 云服务 / FastAPI / OpenAPI / MCP area:core MOS 编排层 / 框架底座 / 跨模块问题 area:docs 文档、示例 area:plugin OpenClaw & Hermes labels Jul 23, 2026
@Memtensor-AI

Memtensor-AI commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2153
Task: 258da3fb72ae3edf
Base: dev-v2.0.24
Head: feat/write-path-content-hash-dedup

🔍 OpenCodeReview found 3 issue(s) in this PR.

⚠️ 1 warning(s) occurred during review.


1. src/memos/memories/textual/tree_text_memory/organize/manager.py (L30)

Unguarded int() conversion of an environment variable will raise ValueError at module import time if MOS_DEDUP_WINDOW_DAYS is set to a non-integer value (e.g., "7.5", "one week", or an empty string ""), crashing the whole application startup. Wrap it in a try/except or use a helper that falls back to the default.

💡 Suggested Change

Before:

_DEDUP_WINDOW_DAYS = int(os.getenv("MOS_DEDUP_WINDOW_DAYS", "7"))

After:

try:
    _DEDUP_WINDOW_DAYS = int(os.getenv("MOS_DEDUP_WINDOW_DAYS", "7"))
except ValueError:
    logger.warning(
        "Invalid MOS_DEDUP_WINDOW_DAYS value %r; falling back to 7 days",
        os.getenv("MOS_DEDUP_WINDOW_DAYS"),
    )
    _DEDUP_WINDOW_DAYS = 7

2. src/memos/memories/textual/tree_text_memory/organize/manager.py (L152)

Timezone-aware vs naive datetime mismatch. datetime.now() returns a naive datetime, but datetime.fromisoformat(updated) returns a timezone-aware datetime when the stored string includes a UTC offset (e.g., "2026-07-20T10:00:00+00:00"). Comparing the two raises TypeError, which is swallowed by the outer except Exception block and treated as fail-open — silently disabling dedup for every node that has a timezone-aware timestamp. Use datetime.now(timezone.utc) and ensure the parsed timestamp is also timezone-aware.

💡 Suggested Change

Before:

                            if ts and ts >= cutoff:

After:

from datetime import datetime, timedelta, timezone

# ...
cutoff = datetime.now(timezone.utc) - timedelta(days=_DEDUP_WINDOW_DAYS)
# ...
                            try:
                                ts = datetime.fromisoformat(updated)
                                # Make naive timestamps UTC-aware for a safe comparison
                                if ts.tzinfo is None:
                                    ts = ts.replace(tzinfo=timezone.utc)
                            except (ValueError, TypeError):
                                ts = None
                            if ts and ts >= cutoff:

3. src/memos/memories/textual/tree_text_memory/organize/manager.py (L169-L176)

The fallback branch m.metadata.info = {"content_hash": h} silently replaces the entire info dict with a new single-key dict, destroying any pre-existing metadata that was stored there. TextualMemoryMetadata.info is a real dict | None field used for arbitrary key-value metadata. If m.metadata.info is not a dict (e.g., None), the correct fix is to initialize it as {"content_hash": h}, but if it already contains data, only the content_hash key should be added or updated.

Additionally, since TextualMemoryMetadata uses model_config = ConfigDict(extra="allow"), the primary m.metadata.content_hash = h assignment on Pydantic models will always succeed — making the entire except block unreachable dead code.

💡 Suggested Change

Before:

            try:
                m.metadata.content_hash = h
            except (AttributeError, TypeError):
                # Metadata model may not support direct attribute set.
                if hasattr(m.metadata, "info") and isinstance(m.metadata.info, dict):
                    m.metadata.info["content_hash"] = h
                else:
                    m.metadata.info = {"content_hash": h}

After:

            # TextualMemoryMetadata uses extra="allow", so direct assignment always works.
            m.metadata.content_hash = h

Generated by cloud-assistant via Open Code Review.

@Zhe-SH-CN
Zhe-SH-CN force-pushed the feat/write-path-content-hash-dedup branch from 2e364ee to ffe0923 Compare July 23, 2026 11:17
@Memtensor-AI Memtensor-AI removed area:plugin OpenClaw & Hermes area:api 云服务 / FastAPI / OpenAPI / MCP area:docs 文档、示例 labels Jul 23, 2026
Fixes MemTensor#2141

Add server-side deduplication on the write path to prevent duplicate
memories from replayed events, retry loops, or buggy client integrations.

Implementation:
- Compute sha1(memory_text) for each incoming TextualMemoryItem
- Dedup within the batch using a set
- Query graph_store for existing nodes with the same content_hash
- Skip items whose hash exists within a configurable time window
- Fail-open: if the dedup check errors, the write proceeds
- Persist content_hash as a metadata property on the graph node
- Toggles: MOS_DEDUP_ENABLED=0 to disable, MOS_DEDUP_WINDOW_DAYS
  to adjust the window (default 7 days)
@Zhe-SH-CN
Zhe-SH-CN force-pushed the feat/write-path-content-hash-dedup branch from ffe0923 to 5c60972 Compare July 23, 2026 11:18
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

✅ Automated Test Results: PASSED

All tests passed (8/8 executed). memos_python_core/changed-repo-python: 8/8. Duration: 5s [advisory, non-gating] AI-generated tests on branch test/auto-gen-258da3fb72ae3edf-20260723200033: 112/120 passed, 8 failed — these do NOT affect the PR verdict; review the branch manually.

Branch: feat/write-path-content-hash-dedup

@Memtensor-AI Memtensor-AI added status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发 and removed status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Jul 23, 2026

@WeiminLee WeiminLee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core MOS 编排层 / 框架底座 / 跨模块问题 area:memory 记忆存储、检索、更新、召回逻辑 status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Write-path dedup: reject identical memory content within a time window (server-side)

6 participants