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.2.0",
"version": "0.2.1",
"category": "productivity",
"homepage": "https://docs.atomicstrata.ai/integrations/coding-agents/claude-code",
"license": "Apache-2.0"
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ jobs:
run: node scripts/ci/release-policy.mjs
- name: Run release-policy unit tests
run: node --test scripts/ci/__tests__/release-policy.test.mjs
# Every surface that can write a verbatim memory must be able to stamp
# content_class. The 0.2.0 release fixed Hermes for Core's raw-content
# policy and missed OpenClaw, whose published plugin could not perform a
# verbatim ingest at all. Tests run first: a conformance check whose own
# logic is unverified is not evidence of anything.
- name: Run ingest contract conformance tests
run: node --test scripts/ci/__tests__/ingest-contract-conformance.test.mjs
- name: Check ingest contract conformance
run: node scripts/check-ingest-contract-conformance.mjs
- name: Run guard unit tests
run: node --test scripts/guards/__tests__/*.test.mjs

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"ci:code-health": "node scripts/ci/code-health.mjs --verify && (turbo run code-health --affected || turbo run code-health)",
"ci:pack-dry-run": "turbo run build && node scripts/ci/pack-dry-run.mjs",
"ci:docs-contract": "turbo run docs-contract",
"ci:public-smoke": "turbo run public-integration-smoke"
"ci:public-smoke": "turbo run public-integration-smoke",
"ci:contract-conformance": "node scripts/check-ingest-contract-conformance.mjs"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.59.3",
Expand Down
21 changes: 15 additions & 6 deletions packages/llmwiki/src/__tests__/live-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { LiveLLMWikiProvider } from "../live/provider.ts";

// A real, private directory. These constructor tests previously hardcoded
// TMP_ROOT, which is a shared path any process on the host can occupy. When it
// exists as a FILE, createWiki() fails with "root exists but is not a
// directory" and the positive cases fail, while the negative cases still pass
// because scope validation throws before touching the filesystem. That made the
// suite dependent on ambient host state rather than hermetic.
const TMP_ROOT = mkdtempSync(path.join(tmpdir(), "llmwiki-provider-"));

const scope = { user: "u1" };
const mk = (root: string) => new LiveLLMWikiProvider({ root, scope, projectId: "proj-1" });

describe("LiveLLMWikiProvider", () => {
it("requires projectId", () => {
assert.throws(
() => new (LiveLLMWikiProvider as any)({ root: "/tmp/x", scope }),
() => new (LiveLLMWikiProvider as any)({ root: TMP_ROOT, scope }),
(e: any) => e?.code === "E_LLMWIKI_PROJECT_ID_REQUIRED",
);
});
Expand Down Expand Up @@ -64,7 +73,7 @@ describe("LiveLLMWikiProvider", () => {
});

it("capabilities advertises text/messages/verbatim + package", () => {
const c = mk("/tmp/whatever").capabilities();
const c = mk(TMP_ROOT).capabilities();
assert.deepEqual(c.ingestModes, ["text", "messages", "verbatim"]);
assert.equal(c.extensions.package, true);
});
Expand Down Expand Up @@ -260,28 +269,28 @@ describe("LiveLLMWikiProvider scope is copied at the boundary (no reference leak
describe("LiveLLMWikiProvider construction scope validation (FIX G)", () => {
it("throws E_LLMWIKI_PROVIDER_SCOPE_MISMATCH when scope is empty {}", () => {
assert.throws(
() => new LiveLLMWikiProvider({ root: "/tmp/x", scope: {}, projectId: "proj-1" }),
() => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: {}, projectId: "proj-1" }),
(e: any) => e?.code === "E_LLMWIKI_PROVIDER_SCOPE_MISMATCH",
);
});

it("throws E_LLMWIKI_PROVIDER_SCOPE_MISMATCH when required field 'user' is missing", () => {
assert.throws(
() => new LiveLLMWikiProvider({ root: "/tmp/x", scope: { namespace: "ns" }, projectId: "proj-1" }),
() => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: { namespace: "ns" }, projectId: "proj-1" }),
(e: any) => e?.code === "E_LLMWIKI_PROVIDER_SCOPE_MISMATCH",
);
});

it("throws E_LLMWIKI_PROVIDER_SCOPE_MISMATCH when 'user' is empty string", () => {
assert.throws(
() => new LiveLLMWikiProvider({ root: "/tmp/x", scope: { user: "" }, projectId: "proj-1" }),
() => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: { user: "" }, projectId: "proj-1" }),
(e: any) => e?.code === "E_LLMWIKI_PROVIDER_SCOPE_MISMATCH",
);
});

it("valid scope { user: 'u1' } constructs without throwing", () => {
assert.doesNotThrow(
() => new LiveLLMWikiProvider({ root: "/tmp/x", scope: { user: "u1" }, projectId: "proj-1" }),
() => new LiveLLMWikiProvider({ root: TMP_ROOT, scope: { user: "u1" }, projectId: "proj-1" }),
);
});
});
Expand Down
8 changes: 7 additions & 1 deletion packages/llmwiki/src/__tests__/live-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { liveLlmwikiProviderFactory, LiveLLMWikiProvider } from "../live.ts";

// Private directory rather than the shared TMP_ROOT: see live-provider.test.ts.
const TMP_ROOT = mkdtempSync(path.join(tmpdir(), "llmwiki-registration-"));

describe("live registration + barrel", () => {
it("factory returns a LiveLLMWikiProvider", () => {
const { provider } = liveLlmwikiProviderFactory({ root: "/tmp/x", scope: { user: "u1" }, projectId: "proj-1" });
const { provider } = liveLlmwikiProviderFactory({ root: TMP_ROOT, scope: { user: "u1" }, projectId: "proj-1" });
assert.ok(provider instanceof LiveLLMWikiProvider);
});
});
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.2.0",
"version": "0.2.1",
"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.2.0",
"version": "0.2.1",
"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.2.0",
"version": "0.2.1",
"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.2.0",
"version": "0.2.1",
"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.2.0"
version: "0.2.1"
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.2.0",
"version": "0.2.1",
"description": "AtomicMemory integration for Cursor - MCP configuration and project rules for persistent semantic memory.",
"private": true,
"license": "Apache-2.0",
Expand Down
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.2.0",
"version": "0.2.1",
"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.2.0
version: 0.2.1
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.2.0"
version = "0.2.1"
description = "AtomicMemory native Hermes memory provider."
readme = "README.md"
requires-python = ">=3.10"
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.2.0",
"version": "0.2.1",
"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.2.0",
"version": "0.2.1",
"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.2.0
version: 0.2.1
author:
name: AtomicMemory
url: https://atomicmem.ai
Expand Down
71 changes: 71 additions & 0 deletions plugins/openclaw/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,77 @@ test('execute lazily creates one MCP caller and parses result details', async ()
assert.deepEqual(first.content, [{ type: 'text', text: '{"ok":true,"call":1}' }]);
});

test('memory_ingest schema accepts contentClass as a top-level property', () => {
const tools = registerWithConfig(plugin);
const ingest = tools.find((tool) => tool.name === 'memory_ingest');
assert.ok(ingest, 'memory_ingest must be registered');

const schema = ingest.parameters as {
additionalProperties?: boolean;
properties?: Record<string, { enum?: string[] }>;
};

// The schema forbids unknown properties, so a missing declaration does not
// degrade to "passed through" - it is rejected before reaching MCP. That is
// what made verbatim ingest impossible against a default-policy core while
// the shipped skill instructions told the agent to send it.
assert.equal(
schema.additionalProperties,
false,
'schema is closed, so contentClass must be declared explicitly',
);
assert.ok(
schema.properties?.contentClass,
'memory_ingest must declare contentClass; the skill instructions require it',
);
assert.deepEqual(schema.properties?.contentClass?.enum, ['summary', 'redacted', 'raw']);
});

test('memory_ingest forwards contentClass to MCP as a top-level argument', async () => {
const toolCalls: Array<{ name: string; arguments?: Record<string, unknown> }> = [];
const testPlugin = createOpenClawPlugin(async () => ({
async callTool(input) {
toolCalls.push(input);
return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] };
},
}));

const tools = registerWithConfig(testPlugin);
const ingest = tools.find((tool) => tool.name === 'memory_ingest');
assert.ok(ingest, 'memory_ingest must be registered');

const params = {
mode: 'verbatim',
content: 'session snapshot',
contentClass: 'summary',
};

// execute() does not itself validate, so tie the payload to the declared
// schema: the host rejects undeclared top-level keys before execute is ever
// reached. Without this, the test would pass against a schema missing
// contentClass and prove nothing about the bug it guards.
const declared = (ingest.parameters as { properties?: Record<string, unknown> }).properties ?? {};
for (const key of Object.keys(params)) {
assert.ok(
key in declared,
`memory_ingest schema must declare '${key}'; the schema is closed so the host would reject this call`,
);
}

await ingest.execute('call-1', params);

assert.equal(toolCalls.length, 1);
// Top-level, NOT nested under metadata. Core reads content_class from the
// top level; a metadata-nested copy is ignored and still 422s, which is
// exactly what the model fell back to when the property was undeclared.
assert.equal(toolCalls[0]?.arguments?.contentClass, 'summary');
assert.equal(
(toolCalls[0]?.arguments?.metadata as Record<string, unknown> | undefined)?.contentClass,
undefined,
'contentClass must not be smuggled through metadata',
);
});

function registerWithConfig(testPlugin: typeof plugin) {
const tools: Array<Parameters<Parameters<typeof testPlugin.register>[0]['registerTool']>[0]> = [];
testPlugin.register({
Expand Down
8 changes: 8 additions & 0 deletions plugins/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ function schemaFor(name: (typeof TOOL_NAMES)[number]): Record<string, unknown> {
mode: enumSchema(['text', 'messages', 'verbatim']),
content: stringSchema(),
messages: { type: 'array' },
// Required by a core running the default RAW_CONTENT_POLICY=reject for
// mode='verbatim': an unstamped verbatim ingest is refused with
// 422 raw_content_rejected. The skill instructions and README already
// tell the agent to set this, but objectSchema pins
// additionalProperties:false, so without the property declared here the
// call was rejected before it left the plugin and verbatim ingest was
// impossible. Forwarded to MCP as a top-level argument.
contentClass: enumSchema(['summary', 'redacted', 'raw']),
scope: scopeSchema(),
metadata: { type: 'object', additionalProperties: true },
provenance: { type: 'object', additionalProperties: true },
Expand Down
Loading
Loading