Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/schema/v1/provider-contract.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 }
Expand Down
25 changes: 25 additions & 0 deletions packages/sdk/src/memory/__tests__/provider-contract-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { ...GOOD_SEARCH_PAGE.retrieval };
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/skills/atomicmemory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
---
Expand Down
2 changes: 1 addition & 1 deletion plugins/cursor/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions plugins/hermes/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions plugins/hermes/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,5 @@ def ingest_verbatim(
scope: Scope,
provenance: Provenance,
metadata: dict[str, Any] | None = None,
content_class: str | None = None,
) -> IngestResult: ...
2 changes: 1 addition & 1 deletion plugins/hermes/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/hermes/plugin.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/hermes/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 14 additions & 9 deletions plugins/hermes/python_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions plugins/hermes/tests/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,15 @@ def ingest_verbatim(
scope: Scope,
provenance: Provenance,
metadata: dict[str, Any] | None = None,
content_class: str | None = None,
) -> IngestResult:
self._record(
"ingest_verbatim",
content=content,
scope=scope,
provenance=provenance,
metadata=metadata,
content_class=content_class,
)
self._maybe_raise("ingest_verbatim")
return self.ingest_response
Expand Down
46 changes: 45 additions & 1 deletion plugins/hermes/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:
Expand Down
31 changes: 30 additions & 1 deletion plugins/hermes/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}

Expand Down Expand Up @@ -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."})

Expand Down
2 changes: 1 addition & 1 deletion plugins/openclaw/openclaw.plugin.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion plugins/openclaw/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/openclaw/skills/atomicmemory/skill.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: atomicmemory
version: 0.1.18
version: 0.2.0
author:
name: AtomicMemory
url: https://atomicmem.ai
Expand Down
Loading