From e11e3d9d593286ba71af709b768449b3bb1bb1bb Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 30 Jul 2026 00:10:54 -0700 Subject: [PATCH] Add content_class to provider contract and require it in Hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a `content_class` field to the verbatim ingest contract in the provider-contract schema, and require the Hermes `atomicmemory_conclude` tool to supply it before storing a fact. ## Changes - Add a `ContentClass` enum (`summary`, `redacted`, `raw`) and a `content_class` field on `VerbatimIngest` in the provider-contract v1 schema. - Forward `content_class` through the Hermes Python SDK client's `ingest_verbatim` call. - Add `content_class` to the `atomicmemory_conclude` tool schema as a required parameter; reject calls that omit it or pass an unrecognized value at the tool boundary, with an error naming the parameter and valid options. - Add schema tests covering accepted, rejected, and unknown `content_class` values, plus provider tests covering the new tool-boundary validation. - Bump plugin package versions to `0.2.0` to reflect the breaking tool-contract change. ## Why AtomicMemory Core's raw-content policy (default: reject) refuses a verbatim write that doesn't declare a content sensitivity class. Previously, the Hermes `conclude` tool sent unclassified writes, which core rejected with a `422` and no indication of which tool call was responsible. Requiring `content_class` at the tool boundary fails closed with a clear, attributable error instead. The class is never inferred by the plugin — the caller must choose it, so a raw transcript can't be silently relabeled as hosted-safe. ## Validation - Added provider-contract schema tests exercising accepted `content_class` values, rejection of unknown values, and rejection of unrelated unknown fields (`packages/sdk/src/memory/__tests__/provider-contract-schema.test.ts`). - Added Hermes provider tests covering forwarding of caller-chosen `content_class`, rejection of a missing value, and rejection of an invalid value (`plugins/hermes/tests/test_provider.py`). --- .claude-plugin/marketplace.json | 2 +- .../schema/v1/provider-contract.schema.json | 7 +++ .../provider-contract-schema.test.ts | 25 ++++++++++ .../claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/package.json | 2 +- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/codex/package.json | 2 +- plugins/codex/skills/atomicmemory/SKILL.md | 2 +- plugins/cursor/package.json | 2 +- plugins/hermes/CHANGELOG.md | 10 ++++ plugins/hermes/client.py | 1 + plugins/hermes/package.json | 2 +- plugins/hermes/plugin.yaml | 2 +- plugins/hermes/pyproject.toml | 2 +- plugins/hermes/python_sdk.py | 23 ++++++---- plugins/hermes/tests/fakes.py | 2 + plugins/hermes/tests/test_provider.py | 46 ++++++++++++++++++- plugins/hermes/tools.py | 31 ++++++++++++- plugins/openclaw/openclaw.plugin.json | 2 +- plugins/openclaw/package.json | 2 +- .../openclaw/skills/atomicmemory/skill.yaml | 2 +- 21 files changed, 147 insertions(+), 24 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index df14050..ad5ef5d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "claude-code", "source": "./plugins/claude-code", "description": "Persistent semantic memory for Claude Code - user preferences, project context, prior decisions, and codebase facts that survive across sessions.", - "version": "0.1.18", + "version": "0.2.0", "category": "productivity", "homepage": "https://docs.atomicstrata.ai/integrations/coding-agents/claude-code", "license": "Apache-2.0" diff --git a/packages/sdk/schema/v1/provider-contract.schema.json b/packages/sdk/schema/v1/provider-contract.schema.json index 03834ea..188b83c 100644 --- a/packages/sdk/schema/v1/provider-contract.schema.json +++ b/packages/sdk/schema/v1/provider-contract.schema.json @@ -33,6 +33,12 @@ "type": "string", "enum": ["fact", "episode", "summary", "procedure", "document"] }, + "ContentClass": { + "title": "ContentClass", + "description": "Sensitivity class of supplied content: 'summary' (distilled, hosted-safe), 'redacted' (sensitive spans removed by the caller), or 'raw' (verbatim prompt/response/diff/source). This contract exposes it on verbatim ingest only; core's HTTP API also consults it on extraction paths, where it decides whether the raw transcript is withheld from the durable audit episode. A core running RAW_CONTENT_POLICY=reject (the default) refuses a verbatim write of 'raw' or unclassified content. Never inferred by a provider — the caller chooses it, so omitting it fails closed rather than mislabeling raw content as safe.", + "type": "string", + "enum": ["summary", "redacted", "raw"] + }, "Message": { "title": "Message", "type": "object", @@ -104,6 +110,7 @@ "mode": { "const": "verbatim" }, "content": { "type": "string" }, "kind": { "$ref": "#/$defs/MemoryKind" }, + "content_class": { "$ref": "#/$defs/ContentClass" }, "scope": { "$ref": "#/$defs/Scope" }, "provenance": { "$ref": "#/$defs/Provenance" }, "metadata": { "type": "object", "additionalProperties": true } diff --git a/packages/sdk/src/memory/__tests__/provider-contract-schema.test.ts b/packages/sdk/src/memory/__tests__/provider-contract-schema.test.ts index 6e3743c..0846b67 100644 --- a/packages/sdk/src/memory/__tests__/provider-contract-schema.test.ts +++ b/packages/sdk/src/memory/__tests__/provider-contract-schema.test.ts @@ -86,6 +86,31 @@ describe('provider-contract v1 schemas', () => { expect(ingest({ ...GOOD_INGEST, mode: 'binary' })).toBe(false); }); + // The SDK forwards content_class to core on the verbatim path, and core + // refuses an unclassified verbatim write under RAW_CONTENT_POLICY=reject. + // VerbatimIngest is additionalProperties:false, so the schema has to carry + // the field or the very payload the SDK emits is contract-invalid. + // Verbatim only, deliberately: core also consults content_class on + // extraction paths for audit-transcript redaction, but exposing it there + // changes what is durably retained — tracked as tech debt, not done here. + it.each(['summary', 'redacted', 'raw'])('accepts content_class %s on verbatim', (contentClass) => { + const { ingest } = buildValidators(); + expect(ingest({ ...GOOD_INGEST, content_class: contentClass })).toBe(true); + }); + + it('rejects an unknown content_class', () => { + const { ingest } = buildValidators(); + expect(ingest({ ...GOOD_INGEST, content_class: 'public' })).toBe(false); + }); + + it('still rejects an unknown ingest field', () => { + // Guards the guard: proves additionalProperties:false is doing work, so + // the acceptance above means the field was added rather than the schema + // having stopped constraining anything. + const { ingest } = buildValidators(); + expect(ingest({ ...GOOD_INGEST, not_a_real_field: 'x' })).toBe(false); + }); + it('rejects a retrieval receipt missing trace_id', () => { const { searchPage } = buildValidators(); const receipt: Record = { ...GOOD_SEARCH_PAGE.retrieval }; diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 3a6d7ee..6101152 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "atomicmemory", - "version": "0.1.18", + "version": "0.2.0", "description": "Persistent semantic memory for Claude Code — user preferences, project context, prior decisions, and codebase facts that survive across sessions.", "author": { "name": "AtomicMemory", diff --git a/plugins/claude-code/package.json b/plugins/claude-code/package.json index 079f1be..19a9eb5 100644 --- a/plugins/claude-code/package.json +++ b/plugins/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/claude-code-plugin", - "version": "0.1.18", + "version": "0.2.0", "description": "AtomicMemory plugin for Claude Code — persistent semantic memory across sessions.", "private": false, "license": "Apache-2.0", diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index da20697..613fbb2 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "atomicmemory", - "version": "0.1.18", + "version": "0.2.0", "description": "AtomicMemory memory layer for Codex. Pluggable semantic memory — swap backends through the SDK's MemoryProvider model by config, not code change.", "author": { "name": "AtomicMemory", diff --git a/plugins/codex/package.json b/plugins/codex/package.json index 6b60c0e..9ec87d1 100644 --- a/plugins/codex/package.json +++ b/plugins/codex/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/codex-plugin", - "version": "0.1.18", + "version": "0.2.0", "description": "AtomicMemory plugin for OpenAI Codex — plugin manifest, MCP server config, and memory protocol skill.", "private": true, "license": "Apache-2.0", diff --git a/plugins/codex/skills/atomicmemory/SKILL.md b/plugins/codex/skills/atomicmemory/SKILL.md index 0819d5b..bbcb29c 100644 --- a/plugins/codex/skills/atomicmemory/SKILL.md +++ b/plugins/codex/skills/atomicmemory/SKILL.md @@ -10,7 +10,7 @@ description: > license: Apache-2.0 metadata: author: AtomicMemory - version: "0.1.18" + version: "0.2.0" category: ai-memory tags: "memory, semantic-search, codex, pluggable" --- diff --git a/plugins/cursor/package.json b/plugins/cursor/package.json index 3e87ff8..f681d33 100644 --- a/plugins/cursor/package.json +++ b/plugins/cursor/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/cursor-plugin", - "version": "0.1.18", + "version": "0.2.0", "description": "AtomicMemory integration for Cursor - MCP configuration and project rules for persistent semantic memory.", "private": true, "license": "Apache-2.0", diff --git a/plugins/hermes/CHANGELOG.md b/plugins/hermes/CHANGELOG.md index 723905b..14d4068 100644 --- a/plugins/hermes/CHANGELOG.md +++ b/plugins/hermes/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 - 2026-07-29 + +### Changed + +- **Breaking (tool contract):** `atomicmemory_conclude` now requires a `content_class` parameter (`summary`, `redacted`, or `raw`) describing the sensitivity of the text being stored. A call that omits it, or passes an unrecognized value, is rejected at the tool boundary with a message naming the parameter. + + This is required to store facts against AtomicMemory Core 1.2.0 and later. Core defaults to `RAW_CONTENT_POLICY=reject`, which refuses a verbatim write carrying no content class — previously `conclude` sent an unclassified write and received `422 raw_content_rejected`, with nothing indicating which tool call caused it. The class is never chosen on the caller's behalf: guessing it could label a raw transcript as hosted-safe. + + Existing deployments need no configuration change, but a model that calls `conclude` without the new parameter will get a tool error instead of a stored fact until it adapts. + ## 0.1.13 - 2026-05-15 ### Fixed diff --git a/plugins/hermes/client.py b/plugins/hermes/client.py index 387a5ac..0d4936b 100644 --- a/plugins/hermes/client.py +++ b/plugins/hermes/client.py @@ -158,4 +158,5 @@ def ingest_verbatim( scope: Scope, provenance: Provenance, metadata: dict[str, Any] | None = None, + content_class: str | None = None, ) -> IngestResult: ... diff --git a/plugins/hermes/package.json b/plugins/hermes/package.json index 9a5b079..a28ab45 100644 --- a/plugins/hermes/package.json +++ b/plugins/hermes/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/hermes-plugin", - "version": "0.1.18", + "version": "0.2.0", "description": "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default.", "publishConfig": { "access": "public", diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index 31dd1d1..8e240b2 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: atomicmemory -version: 0.1.18 +version: 0.2.0 description: "AtomicMemory native Hermes memory provider — Python SDK-backed, cross-tool memory by default." pip_dependencies: - "atomicmemory>=1.1.2,<2.0.0" diff --git a/plugins/hermes/pyproject.toml b/plugins/hermes/pyproject.toml index 49ee55d..daacf9b 100644 --- a/plugins/hermes/pyproject.toml +++ b/plugins/hermes/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "atomicmemory-hermes" -version = "0.1.18" +version = "0.2.0" description = "AtomicMemory native Hermes memory provider." readme = "README.md" requires-python = ">=3.10" diff --git a/plugins/hermes/python_sdk.py b/plugins/hermes/python_sdk.py index 5192b4f..f17c192 100644 --- a/plugins/hermes/python_sdk.py +++ b/plugins/hermes/python_sdk.py @@ -175,16 +175,21 @@ def ingest_verbatim( scope: Scope, provenance: Provenance, metadata: dict[str, Any] | None = None, + content_class: str | None = None, ) -> IngestResult: - raw = self._require_client().ingest( - { - "mode": "verbatim", - "content": content, - "scope": scope, - "provenance": _provenance_dict(provenance), - "metadata": metadata, - } - ) + payload: dict[str, Any] = { + "mode": "verbatim", + "content": content, + "scope": scope, + "provenance": _provenance_dict(provenance), + "metadata": metadata, + } + # Only sent when the caller chose one. Never defaulted here: an + # unclassified write must fail closed at core rather than be relabeled + # safe by the bridge. + if content_class is not None: + payload["content_class"] = content_class + raw = self._require_client().ingest(payload) return _ingest_result(raw) def _require_client(self) -> Any: diff --git a/plugins/hermes/tests/fakes.py b/plugins/hermes/tests/fakes.py index 7209429..75e989b 100644 --- a/plugins/hermes/tests/fakes.py +++ b/plugins/hermes/tests/fakes.py @@ -120,6 +120,7 @@ def ingest_verbatim( scope: Scope, provenance: Provenance, metadata: dict[str, Any] | None = None, + content_class: str | None = None, ) -> IngestResult: self._record( "ingest_verbatim", @@ -127,6 +128,7 @@ def ingest_verbatim( scope=scope, provenance=provenance, metadata=metadata, + content_class=content_class, ) self._maybe_raise("ingest_verbatim") return self.ingest_response diff --git a/plugins/hermes/tests/test_provider.py b/plugins/hermes/tests/test_provider.py index 7e0ca8a..8581073 100644 --- a/plugins/hermes/tests/test_provider.py +++ b/plugins/hermes/tests/test_provider.py @@ -130,7 +130,10 @@ def test_conclude_stamps_provenance_source_hermes(self) -> None: fake = FakeAtomicMemoryClient(ingest_response=IngestResult(created=["m1"])) provider, _ = _make_provider(fake=fake) - provider.handle_tool_call("atomicmemory_conclude", {"conclusion": "user prefers terse"}) + provider.handle_tool_call( + "atomicmemory_conclude", + {"conclusion": "user prefers terse", "content_class": "summary"}, + ) provider.shutdown() call = fake.calls_to("ingest_verbatim")[0] @@ -139,6 +142,47 @@ def test_conclude_stamps_provenance_source_hermes(self) -> None: self.assertTrue(call.kwargs["provenance"].source_url.startswith("hermes://session/")) self.assertEqual(call.kwargs["metadata"], {"kind": "fact"}) + def test_conclude_forwards_the_caller_chosen_content_class(self) -> None: + for chosen in ("summary", "redacted", "raw"): + with self.subTest(content_class=chosen): + fake = FakeAtomicMemoryClient(ingest_response=IngestResult(created=["m1"])) + provider, _ = _make_provider(fake=fake) + + provider.handle_tool_call( + "atomicmemory_conclude", + {"conclusion": "a fact", "content_class": chosen}, + ) + provider.shutdown() + + call = fake.calls_to("ingest_verbatim")[0] + self.assertEqual(call.kwargs["content_class"], chosen) + + def test_conclude_rejects_a_missing_content_class_at_the_tool_boundary(self) -> None: + # Fails here with a message naming the parameter, rather than reaching + # core and returning 422 raw_content_rejected with no indication of + # which tool call caused it. + fake = FakeAtomicMemoryClient(ingest_response=IngestResult(created=["m1"])) + provider, _ = _make_provider(fake=fake) + + raw = provider.handle_tool_call("atomicmemory_conclude", {"conclusion": "a fact"}) + provider.shutdown() + + self.assertIn("content_class", raw) + self.assertEqual(fake.calls_to("ingest_verbatim"), []) + + def test_conclude_rejects_an_unknown_content_class(self) -> None: + fake = FakeAtomicMemoryClient(ingest_response=IngestResult(created=["m1"])) + provider, _ = _make_provider(fake=fake) + + raw = provider.handle_tool_call( + "atomicmemory_conclude", + {"conclusion": "a fact", "content_class": "public"}, + ) + provider.shutdown() + + self.assertIn("Invalid content_class", raw) + self.assertEqual(fake.calls_to("ingest_verbatim"), []) + class SyncTurnUsesIngestMessages(unittest.TestCase): def test_sync_turn_sends_structured_messages_via_worker(self) -> None: diff --git a/plugins/hermes/tools.py b/plugins/hermes/tools.py index eca753b..0aee1fa 100644 --- a/plugins/hermes/tools.py +++ b/plugins/hermes/tools.py @@ -64,8 +64,19 @@ def tool_error(message: str) -> str: "type": "object", "properties": { "conclusion": {"type": "string", "description": "The fact to store verbatim."}, + "content_class": { + "type": "string", + "enum": ["summary", "redacted", "raw"], + "description": ( + "Sensitivity of the text you are storing: 'summary' if you distilled it " + "yourself, 'redacted' if you removed sensitive spans, 'raw' if it is a " + "verbatim prompt, response, diff, or source excerpt. Required: a core " + "running the default raw-content policy refuses raw or unclassified " + "writes, and this tool will not guess on your behalf." + ), + }, }, - "required": ["conclusion"], + "required": ["conclusion", "content_class"], }, } @@ -127,15 +138,33 @@ def _handle_context(provider: AtomicMemoryMemoryProvider, args: dict[str, Any]) ) +# Mirrors the v1 ContentClass enum. Kept local so a malformed tool call is +# rejected at this boundary rather than surfacing later as a core 422 with no +# indication of which tool produced it. +CONTENT_CLASSES = ("summary", "redacted", "raw") + + def _handle_conclude(provider: AtomicMemoryMemoryProvider, args: dict[str, Any]) -> str: conclusion = _clean_str(args.get("conclusion")) if not conclusion: return tool_error("Missing required parameter: conclusion") + content_class = _clean_str(args.get("content_class")) + if not content_class: + return tool_error( + "Missing required parameter: content_class (one of " + f"{', '.join(CONTENT_CLASSES)}). A core running the default raw-content " + "policy rejects unclassified writes; this tool does not choose for you." + ) + if content_class not in CONTENT_CLASSES: + return tool_error( + f"Invalid content_class {content_class!r}; expected one of {', '.join(CONTENT_CLASSES)}." + ) provider._require_client().ingest_verbatim( content=conclusion, scope=ingest_scope_dict(user_id=provider._user_id), provenance=provider._ingest_provenance(session_id=provider._session_id), metadata={"kind": "fact"}, + content_class=content_class, ) return json.dumps({"result": "Fact stored."}) diff --git a/plugins/openclaw/openclaw.plugin.json b/plugins/openclaw/openclaw.plugin.json index 301ac0d..d32d72a 100644 --- a/plugins/openclaw/openclaw.plugin.json +++ b/plugins/openclaw/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "atomicmemory", "name": "AtomicMemory", - "version": "0.1.18", + "version": "0.2.0", "description": "Persistent semantic memory for OpenClaw agents — cross-channel user memory and deterministic session snapshots via the AtomicMemory SDK's pluggable MemoryProvider model.", "kind": "memory", "skills": ["./skills/atomicmemory"], diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index d2e1063..81cdcd5 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@atomicmemory/openclaw-plugin", - "version": "0.1.18", + "version": "0.2.0", "description": "AtomicMemory plugin for OpenClaw — persistent semantic memory and deterministic session snapshots across channels.", "type": "module", "main": "dist/index.js", diff --git a/plugins/openclaw/skills/atomicmemory/skill.yaml b/plugins/openclaw/skills/atomicmemory/skill.yaml index 81ba590..91ee9a4 100644 --- a/plugins/openclaw/skills/atomicmemory/skill.yaml +++ b/plugins/openclaw/skills/atomicmemory/skill.yaml @@ -1,5 +1,5 @@ name: atomicmemory -version: 0.1.18 +version: 0.2.0 author: name: AtomicMemory url: https://atomicmem.ai